@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
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The blog: an index per language, one page per post, a page and a feed per tag, and the "latest
3
+ * posts" section an app's landing page shows. Every page renders through `layout.ts`, so a blog on
4
+ * an app site and one on a developer site look the same (ADR 0120 §4).
5
+ */
6
+ import { posix } from 'node:path';
7
+ import { tagSlug } from '../site-data/index.js';
8
+ import { atomFeed } from './feed.js';
9
+ import { esc, fmt, plain } from './html.js';
10
+ import { icon } from './icons.js';
11
+ import { absoluteUrl, documentPage, pageMasthead, sitePageUrl } from './layout.js';
12
+ import { renderMarkdown } from './markdown.js';
13
+ import { blogPostingJsonLd, metaDescription } from './seo.js';
14
+ /** How many posts an app's landing page shows. */
15
+ export const LATEST_POSTS = 3;
16
+ export function blogIndexUrl(config, lang) {
17
+ return sitePageUrl(config, lang, 'blog/');
18
+ }
19
+ export function blogFeedUrl(config, lang, tag) {
20
+ return sitePageUrl(config, lang, tag ? `blog/tags/${tagSlug(tag)}/feed.xml` : 'blog/feed.xml');
21
+ }
22
+ export function postUrl(config, post) {
23
+ return sitePageUrl(config, post.lang, `blog/${post.slug}/`);
24
+ }
25
+ export function tagUrl(config, lang, tag) {
26
+ return sitePageUrl(config, lang, `blog/tags/${tagSlug(tag)}/`);
27
+ }
28
+ /** The post's own link-preview card, written next to its page. */
29
+ export function postOgImage(config, post) {
30
+ return `${postUrl(config, post)}og.png`;
31
+ }
32
+ const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
33
+ /**
34
+ * Rewrites the URLs a post's Markdown names. A relative image or file resolves against the post's
35
+ * own directory below `<base>blog/` — where the build copies the asset files. A relative link to
36
+ * another post's `.md` file becomes that post's page, so a writer links files the way the editor
37
+ * shows them and the published page links pages.
38
+ */
39
+ export function postUrlResolver(config, post, postsDir, posts) {
40
+ const own = posix.dirname(posix.relative(toPosix(postsDir), toPosix(post.file)));
41
+ const byFile = new Map(posts.map((other) => [posix.relative(toPosix(postsDir), toPosix(other.file)), other]));
42
+ return (url) => {
43
+ if (ABSOLUTE.test(url))
44
+ return url;
45
+ const [path = '', hash] = url.split('#', 2);
46
+ const target = posix.normalize(posix.join(own, decodeURI(path)));
47
+ if (target.startsWith('..'))
48
+ return url;
49
+ if (path.endsWith('.md')) {
50
+ const linked = byFile.get(target);
51
+ // The build never publishes Markdown, so a link to a post it does not render is dead.
52
+ if (!linked)
53
+ throw new Error(`${post.file}: links to ${path}, which is not a published post (a draft?)`);
54
+ return `${postUrl(config, linked)}${hash ? `#${hash}` : ''}`;
55
+ }
56
+ return `${config.base}blog/${encodeURI(target)}${hash ? `#${hash}` : ''}`;
57
+ };
58
+ }
59
+ function toPosix(path) {
60
+ return path.split('\\').join('/');
61
+ }
62
+ export function formatPostDate(date, lang) {
63
+ const time = /^\d{4}-\d{2}-\d{2}$/.test(date) ? `${date}T00:00:00Z` : date;
64
+ return new Intl.DateTimeFormat(lang, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(time));
65
+ }
66
+ /** The first paragraph of the Markdown body, reduced to its text. */
67
+ function firstParagraph(body) {
68
+ const paragraph = body
69
+ .split(/\n\s*\n/)
70
+ .map((block) => block.trim())
71
+ .find((block) => block !== '' && !/^(?:#|!\[|```|~~~|>|[-*+] |\d+\. |\||<)/.test(block));
72
+ return paragraph
73
+ ?.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
74
+ .replace(/[*_`]/g, '')
75
+ .replace(/\s+/g, ' ');
76
+ }
77
+ /** `summary`, or the start of the body when the post has none. */
78
+ export function postDescription(post) {
79
+ return post.summary ?? metaDescription('', firstParagraph(post.body));
80
+ }
81
+ function postMeta(post, shell) {
82
+ const { config, lang, t } = shell;
83
+ const tags = post.tags.map((tag) => `<li><a class="tag" href="${esc(tagUrl(config, lang, tag))}" rel="tag">${esc(tag)}</a></li>`);
84
+ const updated = post.updated && post.updated !== post.date
85
+ ? ` · ${fmt(t('Updated {date}'), { date: formatPostDate(post.updated, lang) })}`
86
+ : '';
87
+ return `<p class="post-meta"><time datetime="${esc(post.date)}">${esc(formatPostDate(post.date, lang))}</time>${updated}${post.draft ? ` · <strong>${esc(t('Draft'))}</strong>` : ''}</p>
88
+ ${tags.length > 0 ? `<ul class="post-tags">${tags.join('')}</ul>` : ''}`;
89
+ }
90
+ function postCard(shell, post, heading) {
91
+ const description = postDescription(post);
92
+ return `<li><article class="post-card">
93
+ <${heading}><a href="${esc(postUrl(shell.config, post))}">${esc(post.title)}</a></${heading}>
94
+ ${postMeta(post, shell)}
95
+ ${description ? `<p>${esc(description)}</p>` : ''}
96
+ </article></li>`;
97
+ }
98
+ function feedLink(shell, href) {
99
+ return `<p class="post-feed"><a href="${esc(href)}">${icon('feed')}<span>${esc(shell.t('Subscribe to the feed'))}</span></a></p>`;
100
+ }
101
+ /** `/<lang>/blog/` or `/<lang>/blog/tags/<tag>/`. */
102
+ export function renderBlogIndex(shell, options) {
103
+ const { config, lang, t } = shell;
104
+ const { tag } = options;
105
+ const inLanguage = options.posts.filter((post) => post.lang === lang && (tag === undefined || post.tags.includes(tag)));
106
+ const urls = {};
107
+ // Every language has an index, if an empty one; a tag page exists only where the tag is used.
108
+ for (const code of config.languages) {
109
+ if (tag === undefined)
110
+ urls[code] = blogIndexUrl(config, code);
111
+ else if (code === lang || options.posts.some((post) => post.lang === code && post.tags.includes(tag)))
112
+ urls[code] = tagUrl(config, code, tag);
113
+ }
114
+ const heading = tag ? plain(t('Posts tagged “{tag}”'), { tag }) : t('Blog');
115
+ const page = {
116
+ title: `${shell.name} – ${heading}`,
117
+ description: tag ? heading : plain(t('Posts by {name}'), { name: shell.name }),
118
+ urls,
119
+ fallback: (code) => blogIndexUrl(config, code),
120
+ ogImage: options.ogImage,
121
+ };
122
+ const list = inLanguage.length > 0
123
+ ? `<ul class="post-list">\n${inLanguage.map((post) => postCard(shell, post, 'h2')).join('\n')}\n</ul>`
124
+ : `<p class="post-empty">${esc(t('No posts in this language yet.'))}</p>`;
125
+ const back = tag
126
+ ? `\n<p class="support-back"><a href="${esc(blogIndexUrl(config, lang))}">${esc(t('All posts'))}</a></p>`
127
+ : '';
128
+ return documentPage(shell, page, `${pageMasthead(shell, heading, shell.name)}
129
+ <main>
130
+ <div class="column blog">
131
+ ${list}
132
+ ${feedLink(shell, blogFeedUrl(config, lang, tag))}${back}
133
+ </div>
134
+ </main>`);
135
+ }
136
+ /** `/<lang>/blog/<slug>/`. */
137
+ export function renderBlogPost(shell, post, options) {
138
+ const { config, lang, t } = shell;
139
+ const urls = {};
140
+ for (const other of options.posts)
141
+ if (other.slug === post.slug)
142
+ urls[other.lang] = postUrl(config, other);
143
+ const description = postDescription(post);
144
+ const url = absoluteUrl(config, postUrl(config, post));
145
+ const ogImage = postOgImage(config, post);
146
+ const page = {
147
+ title: `${post.title} – ${shell.name}`,
148
+ description,
149
+ urls,
150
+ fallback: (code) => blogIndexUrl(config, code),
151
+ ogType: 'article',
152
+ ogImage,
153
+ meta: [
154
+ ['article:published_time', post.date],
155
+ ...(post.updated ? [['article:modified_time', post.updated]] : []),
156
+ ...post.tags.map((tag) => ['article:tag', tag]),
157
+ ],
158
+ jsonLd: blogPostingJsonLd({
159
+ title: post.title,
160
+ description,
161
+ url,
162
+ published: post.date,
163
+ updated: post.updated,
164
+ author: options.author.name,
165
+ authorUrl: options.author.url,
166
+ image: absoluteUrl(config, ogImage),
167
+ lang,
168
+ tags: post.tags,
169
+ }),
170
+ };
171
+ return documentPage(shell, page, `<main>
172
+ <article class="column post">
173
+ <header class="post-head">
174
+ <p class="post-back"><a href="${esc(blogIndexUrl(config, lang))}">${icon('chevron-left')}<span>${esc(t('Blog'))}</span></a></p>
175
+ <h1>${esc(post.title)}</h1>
176
+ ${postMeta(post, shell)}
177
+ </header>
178
+ <div class="post-body">
179
+ ${options.html}
180
+ </div>
181
+ </article>
182
+ </main>`, 'blog-post');
183
+ }
184
+ /** The newest posts as a landing-page section; empty when the language has none. */
185
+ export function latestPostsSection(shell, posts, count = LATEST_POSTS) {
186
+ const { config, lang, t } = shell;
187
+ const latest = posts.filter((post) => post.lang === lang).slice(0, count);
188
+ if (latest.length === 0)
189
+ return '';
190
+ return `<section class="latest-posts">
191
+ <h2>${esc(t('From the blog'))}</h2>
192
+ <ul class="post-list">
193
+ ${latest.map((post) => postCard(shell, post, 'h3')).join('\n')}
194
+ </ul>
195
+ <p class="post-feed"><a href="${esc(blogIndexUrl(config, lang))}">${esc(t('All posts'))}</a></p>
196
+ </section>`;
197
+ }
198
+ export function postKey(post) {
199
+ return `${post.lang}/${post.slug}`;
200
+ }
201
+ /** The Atom feed of one language, or of one tag in it. Relative URLs in the content are made absolute. */
202
+ export function renderBlogFeed(shell, options) {
203
+ const { config, lang, t } = shell;
204
+ const { tag } = options;
205
+ const posts = options.posts.filter((post) => post.lang === lang && (tag === undefined || post.tags.includes(tag)));
206
+ const origin = `${new URL(config.url).origin}`;
207
+ const absolutize = (html) => html.replace(/(\s(?:href|src)=")\//g, `$1${origin}/`);
208
+ const entries = posts.map((post) => {
209
+ const url = absoluteUrl(config, postUrl(config, post));
210
+ return {
211
+ id: url,
212
+ url,
213
+ title: post.title,
214
+ published: post.date,
215
+ updated: post.updated,
216
+ summary: post.summary,
217
+ contentHtml: absolutize(options.html.get(postKey(post)) ?? ''),
218
+ tags: post.tags,
219
+ };
220
+ });
221
+ const site = absoluteUrl(config, tag ? tagUrl(config, lang, tag) : blogIndexUrl(config, lang));
222
+ const self = absoluteUrl(config, blogFeedUrl(config, lang, tag));
223
+ return atomFeed({
224
+ id: self,
225
+ title: tag ? `${shell.name} – ${tag}` : `${shell.name} – ${t('Blog')}`,
226
+ selfUrl: self,
227
+ siteUrl: site,
228
+ lang,
229
+ author: options.author,
230
+ icon: options.iconUrl ? absoluteUrl(config, options.iconUrl) : undefined,
231
+ }, entries);
232
+ }
233
+ /** Every tag in one language, in first-use order. */
234
+ export function tagsOf(posts, lang) {
235
+ return [...new Set(posts.filter((post) => post.lang === lang).flatMap((post) => post.tags))];
236
+ }
237
+ /** Renders a post's Markdown with its URLs resolved. */
238
+ export function renderPostBody(config, post, postsDir, posts) {
239
+ return renderMarkdown(post.body, { resolveUrl: postUrlResolver(config, post, postsDir, posts) });
240
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A developer site: the hub at `/<lang>/` — who the person is, the apps they publish, the latest
3
+ * posts — and the CV at `/<lang>/cv/`, both from a JSON Resume file per language (ADR 0120 §3).
4
+ * The support, privacy and legal-notice pages are the app site's, under the person's name.
5
+ */
6
+ import type { DeveloperData } from '../site-data/index.js';
7
+ import { type ClientAssets, type Shell, type Translate } from './layout.js';
8
+ export interface DeveloperContext {
9
+ data: DeveloperData;
10
+ lang: string;
11
+ t: Translate;
12
+ assets: ClientAssets;
13
+ /** Published URL of `basics.image`: the avatar, the favicon and the bar brand. */
14
+ avatarUrl?: string;
15
+ displayFontUrl?: string;
16
+ /** Root-relative URL of the link-preview card for this language. */
17
+ ogImage?: string;
18
+ }
19
+ export type DeveloperView = 'home' | 'cv' | 'support' | 'privacy' | 'legal-notice';
20
+ export declare function developerPageUrl(ctx: Pick<DeveloperContext, 'data'>, lang: string, view: DeveloperView): string;
21
+ export declare function developerShell(ctx: DeveloperContext, current?: 'blog' | 'cv'): Shell;
22
+ /** `/<lang>/`: the person, their apps, their latest posts. */
23
+ export declare function renderDeveloperHome(ctx: DeveloperContext, latestPosts?: string): string;
24
+ /** `2024-03` → "March 2024" in the reader's language; a bare year stays a year. */
25
+ export declare function formatResumeDate(date: string, lang: string): string;
26
+ /** `/<lang>/cv/`: the résumé, one column on screen, without the site's chrome on paper. */
27
+ export declare function renderCv(ctx: DeveloperContext): string;
28
+ export declare function renderDeveloperSupportPage(ctx: DeveloperContext): string;
29
+ export declare function renderDeveloperPrivacyPage(ctx: DeveloperContext, statement: string): string;
30
+ export declare function renderDeveloperLegalNoticePage(ctx: DeveloperContext, statement: string): string;
@@ -0,0 +1,285 @@
1
+ import { blogFeedUrl, blogIndexUrl } from './blog.js';
2
+ import { esc, fmt, plain } from './html.js';
3
+ import { icon } from './icons.js';
4
+ import { absoluteUrl, documentPage, githubUrl, simplePage, sitePageUrl, } from './layout.js';
5
+ import { renderMarkdown } from './markdown.js';
6
+ import { hostingNotice, linkCard, relatedCard, supportList } from './pages.js';
7
+ import { personJsonLd } from './seo.js';
8
+ const VIEW_PATHS = {
9
+ home: '',
10
+ cv: 'cv/',
11
+ support: 'support/',
12
+ privacy: 'privacy/',
13
+ 'legal-notice': 'legal-notice/',
14
+ };
15
+ export function developerPageUrl(ctx, lang, view) {
16
+ return sitePageUrl(ctx.data.config, lang, VIEW_PATHS[view]);
17
+ }
18
+ function resume(ctx) {
19
+ return ctx.data.resumes[ctx.lang];
20
+ }
21
+ export function developerShell(ctx, current) {
22
+ const { data, lang, t } = ctx;
23
+ const { config } = data;
24
+ const name = resume(ctx).basics.name;
25
+ const nav = [
26
+ ...(config.blog
27
+ ? [
28
+ {
29
+ href: blogIndexUrl(config, lang),
30
+ label: t('Blog'),
31
+ glyph: 'blog',
32
+ current: current === 'blog',
33
+ },
34
+ ]
35
+ : []),
36
+ { href: developerPageUrl(ctx, lang, 'cv'), label: t('CV'), glyph: 'cv', current: current === 'cv' },
37
+ ];
38
+ return {
39
+ config,
40
+ lang,
41
+ t,
42
+ assets: ctx.assets,
43
+ name,
44
+ home: developerPageUrl(ctx, lang, 'home'),
45
+ iconUrl: ctx.avatarUrl,
46
+ // No metainfo `<branding>` to read, so the accent is the developer's colour.
47
+ branding: config.theme.accent ?? {},
48
+ repository: githubUrl(config.repository),
49
+ licenses: [],
50
+ nav,
51
+ feeds: config.blog ? [{ title: `${name} – ${t('Blog')}`, href: blogFeedUrl(config, lang) }] : [],
52
+ displayFontUrl: ctx.displayFontUrl,
53
+ };
54
+ }
55
+ function pageMetaFor(ctx, view, title, description) {
56
+ const { config } = ctx.data;
57
+ return {
58
+ title,
59
+ description,
60
+ urls: Object.fromEntries(config.languages.map((code) => [code, developerPageUrl(ctx, code, view)])),
61
+ ogType: view === 'home' || view === 'cv' ? 'profile' : 'website',
62
+ ogImage: ctx.ogImage,
63
+ };
64
+ }
65
+ const PROFILE_GLYPHS = {
66
+ github: 'code',
67
+ gitlab: 'code',
68
+ codeberg: 'code',
69
+ sourcehut: 'code',
70
+ mastodon: 'people',
71
+ linkedin: 'people',
72
+ xing: 'people',
73
+ email: 'mail',
74
+ };
75
+ function profileGlyph(profile) {
76
+ return PROFILE_GLYPHS[profile.network.toLowerCase()] ?? 'external';
77
+ }
78
+ function profileLinks(profiles) {
79
+ return profiles
80
+ .filter((profile) => profile.url)
81
+ .map((profile) => linkCard(profile.url, profile.network, profileGlyph(profile)));
82
+ }
83
+ /** The words a search result shows: the label and the start of the summary. */
84
+ function developerDescription(resume) {
85
+ const { label, summary, name } = resume.basics;
86
+ const text = [label, summary].filter(Boolean).join(' – ');
87
+ if (!text)
88
+ return name;
89
+ return text.length <= 160 ? text : `${text.slice(0, 159).replace(/\s+\S*$/, '')}…`;
90
+ }
91
+ function hero(ctx) {
92
+ const { basics } = resume(ctx);
93
+ const avatar = ctx.avatarUrl
94
+ ? `<div class="hero-icon developer-avatar"><img src="${esc(ctx.avatarUrl)}" alt="" width="128" height="128"></div>`
95
+ : '';
96
+ return `<header class="masthead">
97
+ <div class="hero-inner">
98
+ ${avatar}
99
+ <div class="hero-text">
100
+ <h1>${esc(basics.name)}</h1>
101
+ ${basics.label ? `<p class="hero-by">${esc(basics.label)}</p>` : ''}
102
+ </div>
103
+ </div>
104
+ </header>`;
105
+ }
106
+ /** `/<lang>/`: the person, their apps, their latest posts. */
107
+ export function renderDeveloperHome(ctx, latestPosts = '') {
108
+ const { data, lang, t } = ctx;
109
+ const cv = resume(ctx);
110
+ const { basics } = cv;
111
+ const description = developerDescription(cv);
112
+ const page = pageMetaFor(ctx, 'home', basics.label ? `${basics.name} – ${basics.label}` : basics.name, description);
113
+ page.jsonLd = personJsonLd({
114
+ name: basics.name,
115
+ url: absoluteUrl(data.config, developerPageUrl(ctx, lang, 'home')),
116
+ jobTitle: basics.label,
117
+ description: basics.summary,
118
+ image: ctx.avatarUrl ? absoluteUrl(data.config, ctx.avatarUrl) : undefined,
119
+ sameAs: basics.profiles.flatMap((profile) => (profile.url ? [profile.url] : [])),
120
+ });
121
+ const profiles = profileLinks(basics.profiles);
122
+ const apps = data.apps.length > 0
123
+ ? `<section class="related developer-apps">
124
+ <h2>${esc(t('Apps'))}</h2>
125
+ <ul class="related-list">
126
+ ${data.apps.map((app) => relatedCard(app, lang)).join('\n')}
127
+ </ul>
128
+ </section>`
129
+ : '';
130
+ const support = data.config.support.length > 0
131
+ ? `<section class="support">
132
+ <a class="button support-cta" href="${esc(developerPageUrl(ctx, lang, 'support'))}">${icon('heart')}<span>${esc(t('Support my work'))}</span></a>
133
+ </section>`
134
+ : '';
135
+ return documentPage(developerShell(ctx), page, `${hero(ctx)}
136
+ <main>
137
+ <div class="column">
138
+ <section class="about">
139
+ ${basics.summary ? renderMarkdown(basics.summary) : ''}
140
+ <p class="developer-cv"><a class="button" href="${esc(developerPageUrl(ctx, lang, 'cv'))}">${icon('cv')}<span>${esc(t('Read my CV'))}</span></a></p>
141
+ </section>
142
+ ${apps}
143
+ ${latestPosts}
144
+ ${support}
145
+ ${profiles.length > 0 ? `<section class="links">\n<h2>${esc(t('Elsewhere'))}</h2>\n<ul class="card-list">\n${profiles.join('\n')}\n</ul>\n</section>` : ''}
146
+ </div>
147
+ </main>`, 'developer');
148
+ }
149
+ /** `2024-03` → "March 2024" in the reader's language; a bare year stays a year. */
150
+ export function formatResumeDate(date, lang) {
151
+ const [year, month] = date.split('-');
152
+ if (!month)
153
+ return year;
154
+ return new Intl.DateTimeFormat(lang, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(Date.UTC(Number(year), Number(month) - 1, 1)));
155
+ }
156
+ function period(start, end, lang, t) {
157
+ if (!start && !end)
158
+ return '';
159
+ const from = start ? formatResumeDate(start, lang) : '';
160
+ const to = end ? formatResumeDate(end, lang) : t('today');
161
+ const text = from ? `${from} – ${to}` : to;
162
+ return `<p class="cv-period">${start ? `<time datetime="${esc(start)}">` : ''}${esc(text)}${start ? '</time>' : ''}</p>`;
163
+ }
164
+ function highlights(items) {
165
+ return items.length > 0
166
+ ? `<ul class="cv-highlights">${items.map((item) => `<li>${esc(item)}</li>`).join('')}</ul>`
167
+ : '';
168
+ }
169
+ function titled(name, url) {
170
+ return url ? `<a href="${esc(url)}">${esc(name)}</a>` : esc(name);
171
+ }
172
+ function workEntry(work, lang, t) {
173
+ return `<li class="cv-entry">
174
+ <h3>${work.position ? `${esc(work.position)} · ` : ''}${titled(work.name, work.url)}</h3>
175
+ ${period(work.startDate, work.endDate, lang, t)}
176
+ ${work.summary ? `<p>${esc(work.summary)}</p>` : ''}
177
+ ${highlights(work.highlights)}
178
+ </li>`;
179
+ }
180
+ function projectEntry(project, lang, t) {
181
+ return `<li class="cv-entry">
182
+ <h3>${titled(project.name, project.url)}</h3>
183
+ ${period(project.startDate, project.endDate, lang, t)}
184
+ ${project.description ? `<p>${esc(project.description)}</p>` : ''}
185
+ ${highlights(project.highlights)}
186
+ ${project.keywords.length > 0 ? `<ul class="tags">${project.keywords.map((keyword) => `<li>${esc(keyword)}</li>`).join('')}</ul>` : ''}
187
+ </li>`;
188
+ }
189
+ function educationEntry(education, lang, t) {
190
+ const what = [education.studyType, education.area].filter(Boolean).join(', ');
191
+ return `<li class="cv-entry">
192
+ <h3>${what ? `${esc(what)} · ` : ''}${titled(education.institution, education.url)}</h3>
193
+ ${period(education.startDate, education.endDate, lang, t)}
194
+ </li>`;
195
+ }
196
+ function cvSection(className, heading, items) {
197
+ if (items.length === 0)
198
+ return '';
199
+ return `<section class="cv-section ${className}">
200
+ <h2>${esc(heading)}</h2>
201
+ <ul class="cv-list">
202
+ ${items.join('\n')}
203
+ </ul>
204
+ </section>`;
205
+ }
206
+ /** `/<lang>/cv/`: the résumé, one column on screen, without the site's chrome on paper. */
207
+ export function renderCv(ctx) {
208
+ const { lang, t } = ctx;
209
+ const cv = resume(ctx);
210
+ const { basics } = cv;
211
+ const location = [basics.location?.city, basics.location?.region].filter(Boolean).join(', ');
212
+ const contact = [
213
+ basics.email ? `<li>${icon('mail')}<a href="mailto:${esc(basics.email)}">${esc(basics.email)}</a></li>` : '',
214
+ basics.url
215
+ ? `<li>${icon('web')}<a href="${esc(basics.url)}">${esc(basics.url.replace(/^https?:\/\/|\/$/g, ''))}</a></li>`
216
+ : '',
217
+ location ? `<li>${esc(location)}</li>` : '',
218
+ ...basics.profiles
219
+ .filter((profile) => profile.url)
220
+ .map((profile) => `<li>${icon(profileGlyph(profile))}<a href="${esc(profile.url)}">${esc(profile.username ?? profile.network)}</a></li>`),
221
+ ].filter(Boolean);
222
+ const skills = cv.skills.map((skill) => `<li class="cv-entry"><h3>${esc(skill.name)}</h3>${skill.keywords.length > 0 ? `<ul class="tags">${skill.keywords.map((keyword) => `<li>${esc(keyword)}</li>`).join('')}</ul>` : ''}</li>`);
223
+ const languages = cv.languages.map((language) => `<li class="cv-entry"><h3>${esc(language.language)}</h3>${language.fluency ? `<p>${esc(language.fluency)}</p>` : ''}</li>`);
224
+ const page = pageMetaFor(ctx, 'cv', `${t('CV')} – ${basics.name}`, plain(t('Experience, projects and skills of {name}.'), { name: basics.name }));
225
+ return documentPage(developerShell(ctx, 'cv'), page, `<main>
226
+ <article class="column cv">
227
+ <header class="cv-head">
228
+ ${ctx.avatarUrl ? `<img class="cv-avatar" src="${esc(ctx.avatarUrl)}" alt="" width="96" height="96">` : ''}
229
+ <div>
230
+ <h1>${esc(basics.name)}</h1>
231
+ ${basics.label ? `<p class="cv-label">${esc(basics.label)}</p>` : ''}
232
+ ${contact.length > 0 ? `<ul class="cv-contact">${contact.join('')}</ul>` : ''}
233
+ </div>
234
+ </header>
235
+ ${basics.summary ? `<section class="cv-section cv-summary">${renderMarkdown(basics.summary)}</section>` : ''}
236
+ ${cvSection('cv-work', t('Experience'), cv.work.map((work) => workEntry(work, lang, t)))}
237
+ ${cvSection('cv-projects', t('Projects'), cv.projects.map((project) => projectEntry(project, lang, t)))}
238
+ ${cvSection('cv-education', t('Education'), cv.education.map((education) => educationEntry(education, lang, t)))}
239
+ ${cvSection('cv-skills', t('Skills'), skills)}
240
+ ${cvSection('cv-languages', t('Languages'), languages)}
241
+ </article>
242
+ </main>`, 'cv-page');
243
+ }
244
+ function back(ctx) {
245
+ return {
246
+ href: developerPageUrl(ctx, ctx.lang, 'home'),
247
+ label: fmt(ctx.t('Back to {app}'), { app: resume(ctx).basics.name }),
248
+ };
249
+ }
250
+ export function renderDeveloperSupportPage(ctx) {
251
+ const { data, t } = ctx;
252
+ const name = resume(ctx).basics.name;
253
+ const description = plain(t('{name} writes free software. Support the work with a transfer or a service.'), {
254
+ name,
255
+ });
256
+ return simplePage(developerShell(ctx), pageMetaFor(ctx, 'support', `${t('Support')} – ${name}`, description), {
257
+ heading: t('Support'),
258
+ byline: name,
259
+ body: `<p class="support-intro">${esc(description)}</p>
260
+ ${supportList(data.config.support, t, name)}`,
261
+ back: back(ctx),
262
+ });
263
+ }
264
+ export function renderDeveloperPrivacyPage(ctx, statement) {
265
+ const { t } = ctx;
266
+ const name = resume(ctx).basics.name;
267
+ return simplePage(developerShell(ctx), pageMetaFor(ctx, 'privacy', `${t('Privacy')} – ${name}`, plain(t('What {app} does with data.'), { app: name })), {
268
+ heading: t('Privacy'),
269
+ byline: name,
270
+ columnClass: 'privacy-statement',
271
+ body: `${hostingNotice(ctx.data.config, t)}\n${renderMarkdown(statement)}`,
272
+ back: back(ctx),
273
+ });
274
+ }
275
+ export function renderDeveloperLegalNoticePage(ctx, statement) {
276
+ const { t } = ctx;
277
+ const name = resume(ctx).basics.name;
278
+ return simplePage(developerShell(ctx), pageMetaFor(ctx, 'legal-notice', `${t('Legal Notice')} – ${name}`, plain(t('Who publishes {app}, and how to reach them.'), { app: name })), {
279
+ heading: t('Legal Notice'),
280
+ byline: name,
281
+ columnClass: 'privacy-statement',
282
+ body: renderMarkdown(statement),
283
+ back: back(ctx),
284
+ });
285
+ }
@@ -0,0 +1,25 @@
1
+ export interface FeedEntry {
2
+ id: string;
3
+ url: string;
4
+ title: string;
5
+ published: string;
6
+ updated?: string;
7
+ summary?: string;
8
+ contentHtml: string;
9
+ tags: readonly string[];
10
+ }
11
+ export interface FeedInfo {
12
+ id: string;
13
+ title: string;
14
+ subtitle?: string;
15
+ selfUrl: string;
16
+ siteUrl: string;
17
+ lang: string;
18
+ author: {
19
+ name: string;
20
+ uri?: string;
21
+ };
22
+ icon?: string;
23
+ }
24
+ /** A complete Atom document. Dates are given as YYYY-MM-DD or ISO; output RFC 3339 (date-only → T00:00:00Z). Feed <updated> = newest entry updated/published, or 1970-01-01T00:00:00Z when empty. */
25
+ export declare function atomFeed(info: FeedInfo, entries: readonly FeedEntry[]): string;