@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
@@ -141,27 +141,75 @@ export interface RelatedApp {
141
141
  url: string;
142
142
  languages: string[];
143
143
  }
144
- /** The `gjsify.site` block of the root package.json, with defaults applied. */
145
- export interface SiteConfig {
146
- metainfo: string;
144
+ /** `gjsify.site.blog`: a directory of Markdown posts, rendered at `/<lang>/blog/`. */
145
+ export interface SiteBlogConfig {
146
+ /** Repo-relative directory holding the `*.md` posts and the images they reference. */
147
+ posts: string;
148
+ }
149
+ /** A colour for each scheme; a single configured colour serves both. */
150
+ export interface SiteColor {
151
+ light: string;
152
+ dark: string;
153
+ }
154
+ /** `gjsify.site.theme`: how a site departs from the default look. Every key is optional. */
155
+ export interface SiteThemeConfig {
156
+ /** Links, buttons and focus rings. Default: the stylesheet's Adwaita blue. */
157
+ accent?: SiteColor;
158
+ /** Repo-relative woff2 file used for headings; the package ships and fetches no font itself. */
159
+ displayFont?: string;
160
+ /** `pixelated` keeps pixel art crisp: CSS `image-rendering` and nearest-neighbour renditions. */
161
+ imageRendering: 'auto' | 'pixelated';
162
+ }
163
+ /** The keys that mean the same on an app site and on a developer site. */
164
+ export interface SiteCommonConfig {
147
165
  base: string;
148
166
  url: string;
149
167
  languages: string[];
150
168
  /** Language `/` falls back to when the visitor's languages match none of `languages`. */
151
169
  defaultLanguage: string;
152
- /** Language of the metainfo's untranslated text; the other languages are looked up in the catalogues. */
170
+ /** Language of the untranslated source text; the other languages are looked up in the catalogues. */
153
171
  sourceLanguage: string;
154
- repository: string;
155
- flathub?: string;
156
- webApp?: string;
157
- /** Directory holding the app's `<lang>.po` catalogues, used when the metainfo lacks a translation. */
158
- translations?: string;
159
172
  /**
160
173
  * Directory holding `<lang>.po` catalogues for the strings this configuration itself introduces —
161
174
  * the download labels. They overlay the page catalogues shipped with the renderer, which cannot
162
175
  * know a consumer's own copy.
163
176
  */
164
177
  pageTranslations?: string;
178
+ support: SiteSupportEntry[];
179
+ legal: SiteLegalLink[];
180
+ /** A privacy/data-practices page at `/<lang>/privacy/`. `undefined`: no such page is built. */
181
+ privacy?: SitePrivacyConfig;
182
+ /** A Legal Notice / Impressum page at `/<lang>/legal-notice/`. `undefined`: no such page is built. */
183
+ legalNotice?: SiteLegalNoticeConfig;
184
+ /** `undefined`: no blog pages. */
185
+ blog?: SiteBlogConfig;
186
+ theme: SiteThemeConfig;
187
+ }
188
+ /** `gjsify.site.developer`: a page about a person and the apps they publish. */
189
+ export interface SiteDeveloperConfig {
190
+ /** A JSON Resume file per configured language, repo-relative. */
191
+ resume: Record<string, string>;
192
+ /** Same format as `relatedApps`, in the order the hub lists them. */
193
+ apps: SiteRelatedAppConfig[];
194
+ }
195
+ /** A developer site: `gjsify.site` with `developer` instead of `metainfo`. */
196
+ export interface DeveloperSiteConfig extends SiteCommonConfig {
197
+ kind: 'developer';
198
+ developer: SiteDeveloperConfig;
199
+ /** Source of the site itself, linked from the footer. */
200
+ repository?: string;
201
+ }
202
+ /** Either kind of `gjsify.site`, told apart by `kind`. */
203
+ export type AnySiteConfig = SiteConfig | DeveloperSiteConfig;
204
+ /** The `gjsify.site` block of an app's root package.json, with defaults applied. */
205
+ export interface SiteConfig extends SiteCommonConfig {
206
+ kind: 'app';
207
+ metainfo: string;
208
+ repository: string;
209
+ flathub?: string;
210
+ webApp?: string;
211
+ /** Directory holding the app's `<lang>.po` catalogues, used when the metainfo lacks a translation. */
212
+ translations?: string;
165
213
  /** Repo-relative app icon; defaults to the Flatpak or `ship` icon of the root package.json. */
166
214
  icon?: string;
167
215
  /**
@@ -173,12 +221,6 @@ export interface SiteConfig {
173
221
  downloads: SiteDownloadConfig[];
174
222
  /** False ignores the latest release's assets, so `downloads` is the whole list. Default true. */
175
223
  releaseAssets: boolean;
176
- support: SiteSupportEntry[];
177
- legal: SiteLegalLink[];
178
- /** A privacy/data-practices page at `/<lang>/privacy/`. `undefined`: no such page is built. */
179
- privacy?: SitePrivacyConfig;
180
- /** A Legal Notice / Impressum page at `/<lang>/legal-notice/`. `undefined`: no such page is built. */
181
- legalNotice?: SiteLegalNoticeConfig;
182
224
  /** Sites of other apps to list under "More apps by <developer>", in order. Empty: no section. */
183
225
  relatedApps: SiteRelatedAppConfig[];
184
226
  }
package/lib/site.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface SiteTarget {
9
9
  export interface SiteBuildOptions extends SiteTarget {
10
10
  /** Skip every network call; the page is rendered from what is in the repository. */
11
11
  offline?: boolean;
12
+ /** Render blog posts marked `draft: true`, for a local preview. */
13
+ drafts?: boolean;
12
14
  /**
13
15
  * Where the one-hour HTTP response cache and the image renditions live; `false` disables the
14
16
  * response cache. A published package must not write inside its own install, so the default is
@@ -47,29 +49,3 @@ export interface SiteI18nOptions {
47
49
  * own `po/` when called without a root, otherwise the consumer's `pageTranslations`.
48
50
  */
49
51
  export declare function syncSiteI18n(options?: SiteI18nOptions): Promise<I18nResult>;
50
- /**
51
- * The `--overview` input: one JSON file naming the host and the apps whose support pages it lists.
52
- * Each app is a repository root (relative to the file) with its own `gjsify.site` block, which is
53
- * where its published address, languages and metainfo already live.
54
- */
55
- export interface OverviewConfig {
56
- /** Origin the overview is published on, e.g. `https://jumplink.eu/`. */
57
- url: string;
58
- /** Path the overview lives under; default `/support/`. */
59
- base?: string;
60
- languages: string[];
61
- defaultLanguage?: string;
62
- apps: string[];
63
- }
64
- export interface OverviewBuildOptions {
65
- /** The overview JSON file. */
66
- config: string;
67
- out: string;
68
- log?: (message: string) => void;
69
- warn?: (message: string) => void;
70
- packageDir?: string;
71
- }
72
- export declare function buildOverview(options: OverviewBuildOptions): Promise<{
73
- languages: string[];
74
- apps: number;
75
- }>;
package/lib/site.js CHANGED
@@ -9,11 +9,13 @@ import { catalogMsgids, configMsgids, rendererMsgids, syncCatalogs } from './ren
9
9
  import { readableText } from './render/color.js';
10
10
  import { svgToPng } from './render/image-backend.js';
11
11
  import { processImage } from './render/images.js';
12
+ import { blogFeedUrl, blogIndexUrl, latestPostsSection, postDescription, postKey, postOgImage, postUrl, renderBlogFeed, renderBlogIndex, renderBlogPost, renderPostBody, tagsOf, tagUrl, } from './render/blog.js';
13
+ import { developerPageUrl, developerShell, renderCv, renderDeveloperHome, renderDeveloperLegalNoticePage, renderDeveloperPrivacyPage, renderDeveloperSupportPage, } from './render/developer.js';
12
14
  import { ogCardSvg } from './render/og-card.js';
13
- import { absolute, legalNoticeUrl, localized, pageMeta, pageUrl, privacyUrl, renderLegalNoticePage, renderPage, renderPrivacyPage, renderRoot, renderSupportPage, supportUrl, } from './render/pages.js';
14
- import { renderOverview } from './render/overview.js';
15
- import { robotsTxt, seoWarnings, sitemapXml } from './render/seo.js';
16
- import { UI_SOURCE_LANGUAGE, loadCatalogs, loadSiteData, mergeCatalogs, parseMetainfo, parseSiteConfig, pickByLanguage, translator, APP_MANIFEST_FILE, buildAppManifest, loadLegalNoticeStatements, loadPrivacyStatements, } from './site-data/index.js';
15
+ import { absolute, appShell, legalNoticeUrl, localized, pageMeta, pageUrl, privacyUrl, renderLegalNoticePage, renderPage, renderPrivacyPage, renderRoot, renderSupportPage, supportUrl, } from './render/pages.js';
16
+ import { absoluteUrl, rootPage } from './render/layout.js';
17
+ import { neighbourSitemaps, robotsTxt, seoWarnings, sitemapXml } from './render/seo.js';
18
+ import { UI_SOURCE_LANGUAGE, loadCatalogs, loadSiteData, mergeCatalogs, blogAssetFiles, loadBlogPosts, loadDeveloperData, parseAnySiteConfig, translator, APP_MANIFEST_FILE, buildAppManifest, loadLegalNoticeStatements, loadPrivacyStatements, } from './site-data/index.js';
17
19
  /**
18
20
  * This package's own directory: `lib/` holds the client bundle, `po/` the page catalogues.
19
21
  *
@@ -33,12 +35,29 @@ export function defaultCacheDir(root) {
33
35
  }
34
36
  async function readConfig(root) {
35
37
  const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
36
- return parseSiteConfig(manifest);
38
+ return parseAnySiteConfig(manifest);
37
39
  }
38
40
  /** Short, stable and enough to bust a cache: the first bytes of the content's SHA-256. */
39
41
  function contentHash(content) {
40
42
  return createHash('sha256').update(content).digest('hex').slice(0, 8);
41
43
  }
44
+ async function writePage(out, path, html) {
45
+ await mkdir(dirname(join(out, path)), { recursive: true });
46
+ await writeFile(join(out, path), html);
47
+ }
48
+ /** Copies a repository file to `assets/` under a content-addressed name; answers its URL. */
49
+ async function publishHashed(source, out, base) {
50
+ const content = await readFile(source);
51
+ const extension = extname(source);
52
+ const name = `${basename(source, extension)}-${contentHash(content)}${extension}`;
53
+ await mkdir(join(out, 'assets'), { recursive: true });
54
+ await writeFile(join(out, 'assets', name), content);
55
+ return `${base}assets/${name}`;
56
+ }
57
+ async function publishDisplayFont(config, { root, out }) {
58
+ const font = config.theme.displayFont;
59
+ return font ? publishHashed(resolve(root, font), out, config.base) : undefined;
60
+ }
42
61
  /**
43
62
  * Stages the client bundle and the stylesheet this package ships, under content-addressed names.
44
63
  * Both are built once, when the package is built — a site build neither needs a bundler nor an
@@ -61,6 +80,7 @@ async function processScreenshots(data, { out }, cacheDir) {
61
80
  outDir: join(out, 'img'),
62
81
  cacheDir: join(cacheDir, 'images'),
63
82
  urlBase: `${data.config.base}img/`,
83
+ pixelated: data.config.theme.imageRendering === 'pixelated',
64
84
  };
65
85
  const entries = await Promise.all(data.screenshots.map(async (screenshot) => [screenshot.slug, await processImage(screenshot.file, screenshot.slug, options)]));
66
86
  return new Map(entries);
@@ -106,6 +126,68 @@ async function loadPageCatalogs(config, root, self, warn) {
106
126
  }
107
127
  return catalogs;
108
128
  }
129
+ /** Reads the posts and copies the files next to them to `<out>/blog/`, where the post URLs point. */
130
+ async function loadBlog(config, target, drafts) {
131
+ if (!config.blog)
132
+ return undefined;
133
+ const dir = resolve(target.root, config.blog.posts);
134
+ const posts = await loadBlogPosts({ dir, languages: config.languages, drafts });
135
+ for (const file of await blogAssetFiles(dir)) {
136
+ await mkdir(dirname(join(target.out, 'blog', file)), { recursive: true });
137
+ await copyFile(join(dir, file), join(target.out, 'blog', file));
138
+ }
139
+ const html = new Map(posts.map((post) => [postKey(post), renderPostBody(config, post, dir, posts)]));
140
+ return { posts, html };
141
+ }
142
+ /** Writes the blog of every language and answers its sitemap entries. */
143
+ async function writeBlog(config, out, pages) {
144
+ const { blog } = pages;
145
+ const absoluteAll = (urls) => Object.fromEntries(Object.entries(urls).map(([lang, url]) => [lang, absoluteUrl(config, url)]));
146
+ const sitemap = [
147
+ absoluteAll(Object.fromEntries(config.languages.map((lang) => [lang, blogIndexUrl(config, lang)]))),
148
+ ];
149
+ const relative = (url) => url.slice(config.base.length);
150
+ for (const lang of config.languages) {
151
+ const shell = pages.shell(lang);
152
+ const feed = { posts: blog.posts, author: pages.author, html: blog.html, iconUrl: pages.iconUrl };
153
+ await writePage(out, `${relative(blogIndexUrl(config, lang))}index.html`, renderBlogIndex(shell, { posts: blog.posts, ogImage: pages.ogImage(lang) }));
154
+ await writePage(out, relative(blogFeedUrl(config, lang)), renderBlogFeed(shell, feed));
155
+ for (const tag of tagsOf(blog.posts, lang)) {
156
+ await writePage(out, `${relative(tagUrl(config, lang, tag))}index.html`, renderBlogIndex(shell, { posts: blog.posts, tag, ogImage: pages.ogImage(lang) }));
157
+ await writePage(out, relative(blogFeedUrl(config, lang, tag)), renderBlogFeed(shell, { ...feed, tag }));
158
+ sitemap.push({ [lang]: absoluteUrl(config, tagUrl(config, lang, tag)) });
159
+ }
160
+ for (const post of blog.posts.filter((candidate) => candidate.lang === lang)) {
161
+ const html = blog.html.get(postKey(post)) ?? '';
162
+ await writePage(out, `${relative(postUrl(config, post))}index.html`, renderBlogPost(shell, post, {
163
+ posts: blog.posts,
164
+ html,
165
+ author: { name: pages.author.name, url: pages.author.uri },
166
+ }));
167
+ const card = ogCardSvg({ ...pages.card, name: post.title, summary: postDescription(post) });
168
+ await writeFile(join(out, relative(postOgImage(config, post))), svgToPng(card));
169
+ }
170
+ }
171
+ for (const slug of new Set(blog.posts.map((post) => post.slug))) {
172
+ const translations = blog.posts.filter((post) => post.slug === slug);
173
+ sitemap.push(absoluteAll(Object.fromEntries(translations.map((post) => [post.lang, postUrl(config, post)]))));
174
+ }
175
+ return sitemap;
176
+ }
177
+ function writeSitemap(config, out, pages, apps) {
178
+ return [
179
+ writeFile(join(out, 'sitemap.xml'), sitemapXml(pages, config.defaultLanguage)),
180
+ // Only a site served from the host root can own `/robots.txt`; below a path the host does.
181
+ ...(config.base === '/'
182
+ ? [
183
+ writeFile(join(out, 'robots.txt'), robotsTxt([
184
+ absoluteUrl(config, `${config.base}sitemap.xml`),
185
+ ...neighbourSitemaps(config.url, apps),
186
+ ])),
187
+ ]
188
+ : []),
189
+ ];
190
+ }
109
191
  /** Renders one static page per configured language, plus the language-picking root page. */
110
192
  export async function buildSite(options) {
111
193
  const target = { root: resolve(options.root), out: resolve(options.out) };
@@ -116,6 +198,13 @@ export async function buildSite(options) {
116
198
  await mkdir(target.out, { recursive: true });
117
199
  const assets = await stageClientAssets(config.base, target.out, self);
118
200
  const catalogs = await loadPageCatalogs(config, target.root, self, options.warn);
201
+ const displayFontUrl = await publishDisplayFont(config, target);
202
+ const blog = await loadBlog(config, target, options.drafts ?? false);
203
+ const build = { options, target, cacheDir, assets, catalogs, displayFontUrl, blog };
204
+ return config.kind === 'app' ? buildAppSite(build) : buildDeveloperSite(config, build);
205
+ }
206
+ async function buildAppSite(build) {
207
+ const { options, target, cacheDir, assets, catalogs, displayFontUrl, blog } = build;
119
208
  const data = await loadSiteData({
120
209
  root: target.root,
121
210
  screenshotCacheDir: join(cacheDir ?? defaultCacheDir(target.root), 'screenshots'),
@@ -128,6 +217,7 @@ export async function buildSite(options) {
128
217
  const iconUrl = await publishIcon(data, target);
129
218
  const iconHref = await iconDataUri(data, target);
130
219
  const { light = '#84bbfd' } = data.app.branding;
220
+ const card = { background: light, foreground: readableText(light), iconHref };
131
221
  const hasSupport = data.config.support.length > 0;
132
222
  const privacyStatements = data.config.privacy
133
223
  ? await loadPrivacyStatements(data.config.privacy, target.root)
@@ -135,33 +225,33 @@ export async function buildSite(options) {
135
225
  const legalNoticeStatements = data.config.legalNotice
136
226
  ? await loadLegalNoticeStatements(data.config.legalNotice, target.root)
137
227
  : undefined;
228
+ const contextFor = (lang) => ({
229
+ data,
230
+ lang,
231
+ t: translator(catalogs[lang]),
232
+ assets,
233
+ images,
234
+ iconUrl,
235
+ displayFontUrl,
236
+ ogImage: `${data.config.base}og-${lang}.png`,
237
+ });
138
238
  for (const lang of data.config.languages) {
139
- const t = translator(catalogs[lang]);
140
- const ctx = { data, lang, t, assets, images, iconUrl, ogImage: `${data.config.base}og-${lang}.png` };
239
+ const ctx = contextFor(lang);
240
+ if (blog)
241
+ ctx.latestPosts = latestPostsSection(appShell(ctx), blog.posts);
141
242
  const meta = pageMeta(ctx);
142
243
  for (const warning of seoWarnings({ lang, ...meta }))
143
244
  options.warn?.(warning);
144
- const card = ogCardSvg({
145
- name: meta.name,
146
- summary: localized(data.app.summary, lang),
147
- background: light,
148
- foreground: readableText(light),
149
- iconHref,
150
- });
151
- await writeFile(join(target.out, `og-${lang}.png`), svgToPng(card));
152
- await mkdir(join(target.out, lang), { recursive: true });
153
- await writeFile(join(target.out, lang, 'index.html'), renderPage(ctx));
154
- if (hasSupport) {
155
- await mkdir(join(target.out, lang, 'support'), { recursive: true });
156
- await writeFile(join(target.out, lang, 'support', 'index.html'), renderSupportPage(ctx));
157
- }
245
+ const summary = localized(data.app.summary, lang);
246
+ await writeFile(join(target.out, `og-${lang}.png`), svgToPng(ogCardSvg({ ...card, name: meta.name, summary })));
247
+ await writePage(target.out, `${lang}/index.html`, renderPage(ctx));
248
+ if (hasSupport)
249
+ await writePage(target.out, `${lang}/support/index.html`, renderSupportPage(ctx));
158
250
  if (privacyStatements) {
159
- await mkdir(join(target.out, lang, 'privacy'), { recursive: true });
160
- await writeFile(join(target.out, lang, 'privacy', 'index.html'), renderPrivacyPage(ctx, privacyStatements[lang]));
251
+ await writePage(target.out, `${lang}/privacy/index.html`, renderPrivacyPage(ctx, privacyStatements[lang]));
161
252
  }
162
253
  if (legalNoticeStatements) {
163
- await mkdir(join(target.out, lang, 'legal-notice'), { recursive: true });
164
- await writeFile(join(target.out, lang, 'legal-notice', 'index.html'), renderLegalNoticePage(ctx, legalNoticeStatements[lang]));
254
+ await writePage(target.out, `${lang}/legal-notice/index.html`, renderLegalNoticePage(ctx, legalNoticeStatements[lang]));
165
255
  }
166
256
  }
167
257
  const manifest = buildAppManifest(data, iconUrl ? absolute(data, iconUrl) : undefined);
@@ -174,13 +264,105 @@ export async function buildSite(options) {
174
264
  ...(privacyStatements ? [address(privacyUrl)] : []),
175
265
  ...(legalNoticeStatements ? [address(legalNoticeUrl)] : []),
176
266
  ];
177
- await writeFile(join(target.out, 'sitemap.xml'), sitemapXml(pages, data.config.defaultLanguage));
178
- // Only a site served from the host root can own `/robots.txt`; below a path the host does.
179
- if (data.config.base === '/') {
180
- await writeFile(join(target.out, 'robots.txt'), robotsTxt(absolute(data, `${data.config.base}sitemap.xml`)));
267
+ if (blog) {
268
+ const developer = data.app.developer.name;
269
+ pages.push(...(await writeBlog(data.config, target.out, {
270
+ blog,
271
+ shell: (lang) => appShell({ ...contextFor(lang), current: 'blog' }),
272
+ author: { name: developer || localized(data.app.name, data.config.defaultLanguage) },
273
+ card,
274
+ iconUrl,
275
+ ogImage: (lang) => `${data.config.base}og-${lang}.png`,
276
+ })));
181
277
  }
278
+ await Promise.all(writeSitemap(data.config, target.out, pages, data.config.relatedApps));
182
279
  return { languages: [...data.config.languages], screenshots: images.size };
183
280
  }
281
+ const HTTP_URL = /^https?:\/\//;
282
+ /** `basics.image`: a URL is used as it is, a repository file is published under `assets/`. */
283
+ async function publishAvatar(data, target) {
284
+ const image = data.resumes[data.config.defaultLanguage]?.basics.image;
285
+ if (!image)
286
+ return undefined;
287
+ return HTTP_URL.test(image) ? image : publishHashed(resolve(target.root, image), target.out, data.config.base);
288
+ }
289
+ async function avatarDataUri(data, { root }) {
290
+ const image = data.resumes[data.config.defaultLanguage]?.basics.image;
291
+ if (!image || HTTP_URL.test(image))
292
+ return undefined;
293
+ const type = ICON_TYPES[extname(image).toLowerCase()];
294
+ if (!type)
295
+ return undefined;
296
+ return `data:${type};base64,${(await readFile(resolve(root, image))).toString('base64')}`;
297
+ }
298
+ async function buildDeveloperSite(config, build) {
299
+ const { options, target, cacheDir, assets, catalogs, displayFontUrl, blog } = build;
300
+ const data = await loadDeveloperData(config, {
301
+ root: target.root,
302
+ offline: options.offline ?? false,
303
+ network: cacheDir ? { cacheDir: join(cacheDir, 'http') } : {},
304
+ warn: options.warn,
305
+ });
306
+ const avatarUrl = await publishAvatar(data, target);
307
+ const background = config.theme.accent?.light ?? '#84bbfd';
308
+ const card = { background, foreground: readableText(background), iconHref: await avatarDataUri(data, target) };
309
+ const privacyStatements = config.privacy ? await loadPrivacyStatements(config.privacy, target.root) : undefined;
310
+ const legalNoticeStatements = config.legalNotice
311
+ ? await loadLegalNoticeStatements(config.legalNotice, target.root)
312
+ : undefined;
313
+ const contextFor = (lang) => ({
314
+ data,
315
+ lang,
316
+ t: translator(catalogs[lang]),
317
+ assets,
318
+ avatarUrl,
319
+ displayFontUrl,
320
+ ogImage: `${config.base}og-${lang}.png`,
321
+ });
322
+ for (const lang of config.languages) {
323
+ const ctx = contextFor(lang);
324
+ const { basics } = data.resumes[lang];
325
+ await writeFile(join(target.out, `og-${lang}.png`), svgToPng(ogCardSvg({ ...card, name: basics.name, summary: basics.label ?? '' })));
326
+ const latest = blog ? latestPostsSection(developerShell(ctx), blog.posts) : '';
327
+ await writePage(target.out, `${lang}/index.html`, renderDeveloperHome(ctx, latest));
328
+ await writePage(target.out, `${lang}/cv/index.html`, renderCv(ctx));
329
+ if (config.support.length > 0) {
330
+ await writePage(target.out, `${lang}/support/index.html`, renderDeveloperSupportPage(ctx));
331
+ }
332
+ if (privacyStatements) {
333
+ await writePage(target.out, `${lang}/privacy/index.html`, renderDeveloperPrivacyPage(ctx, privacyStatements[lang]));
334
+ }
335
+ if (legalNoticeStatements) {
336
+ await writePage(target.out, `${lang}/legal-notice/index.html`, renderDeveloperLegalNoticePage(ctx, legalNoticeStatements[lang]));
337
+ }
338
+ }
339
+ const name = data.resumes[config.defaultLanguage].basics.name;
340
+ await writeFile(join(target.out, 'index.html'), rootPage(config, name, assets, avatarUrl));
341
+ const address = (view) => Object.fromEntries(config.languages.map((lang) => [lang, absoluteUrl(config, developerPageUrl({ data }, lang, view))]));
342
+ const pages = [
343
+ address('home'),
344
+ address('cv'),
345
+ ...(config.support.length > 0 ? [address('support')] : []),
346
+ ...(privacyStatements ? [address('privacy')] : []),
347
+ ...(legalNoticeStatements ? [address('legal-notice')] : []),
348
+ ];
349
+ if (blog) {
350
+ const author = data.resumes[config.defaultLanguage].basics;
351
+ pages.push(...(await writeBlog(config, target.out, {
352
+ blog,
353
+ shell: (lang) => developerShell(contextFor(lang), 'blog'),
354
+ author: {
355
+ name: author.name,
356
+ uri: absoluteUrl(config, developerPageUrl({ data }, config.defaultLanguage, 'home')),
357
+ },
358
+ card,
359
+ iconUrl: avatarUrl,
360
+ ogImage: (lang) => `${config.base}og-${lang}.png`,
361
+ })));
362
+ }
363
+ await Promise.all(writeSitemap(config, target.out, pages, config.developer.apps));
364
+ return { languages: [...config.languages], screenshots: 0 };
365
+ }
184
366
  /** The languages this package keeps a catalogue for: whichever `po/<lang>.po` files exist. */
185
367
  async function packageLanguages(dir) {
186
368
  const files = await readdir(dir);
@@ -222,52 +404,3 @@ export async function syncSiteI18n(options = {}) {
222
404
  await mkdir(dir, { recursive: true });
223
405
  return syncCatalogs({ dir, languages: config.languages, msgids, check, base: root });
224
406
  }
225
- export async function buildOverview(options) {
226
- const file = resolve(options.config);
227
- const overview = JSON.parse(await readFile(file, 'utf8'));
228
- if (!overview.url || !Array.isArray(overview.apps) || !Array.isArray(overview.languages))
229
- throw new Error(`${file} needs "url", "languages" and "apps"`);
230
- const out = resolve(options.out);
231
- const base = overview.base ?? '/support/';
232
- const defaultLanguage = overview.defaultLanguage ?? overview.languages[0];
233
- const self = selfDir(options.packageDir);
234
- await rm(out, { recursive: true, force: true });
235
- await mkdir(out, { recursive: true });
236
- const assets = await stageClientAssets(base, out, self);
237
- const catalogs = await loadCatalogs(poDir(self), overview.languages);
238
- const apps = await Promise.all(overview.apps.map(async (entry) => {
239
- const root = resolve(dirname(file), entry);
240
- const config = await readConfig(root);
241
- const metainfo = parseMetainfo(await readFile(resolve(root, config.metainfo), 'utf8'));
242
- return { config, metainfo };
243
- }));
244
- const origin = new URL(overview.url).origin;
245
- const pathFor = (lang) => `${base}${lang}/`;
246
- const urls = Object.fromEntries(overview.languages.map((lang) => [lang, `${origin}${pathFor(lang)}`]));
247
- const paths = Object.fromEntries(overview.languages.map((lang) => [lang, pathFor(lang)]));
248
- for (const lang of overview.languages) {
249
- const html = renderOverview({
250
- lang,
251
- languages: overview.languages,
252
- defaultLanguage,
253
- urls,
254
- paths,
255
- assets,
256
- t: translator(catalogs[lang]),
257
- apps: apps.map(({ config, metainfo }) => ({
258
- name: pickByLanguage(metainfo.name.byLanguage, lang) ?? metainfo.name.source,
259
- url: `${new URL(config.url).origin}${config.base}${config.languages.includes(lang) ? lang : config.defaultLanguage}/support/`,
260
- })),
261
- });
262
- await mkdir(join(out, lang), { recursive: true });
263
- await writeFile(join(out, lang, 'index.html'), html);
264
- }
265
- const root = `<!doctype html>
266
- <html lang="${defaultLanguage}"><head><meta charset="utf-8"><title>Support</title>
267
- <link rel="canonical" href="${urls[defaultLanguage]}"><meta http-equiv="refresh" content="0; url=${pathFor(defaultLanguage)}"></head>
268
- <body><a href="${pathFor(defaultLanguage)}">Support</a></body></html>
269
- `;
270
- await writeFile(join(out, 'index.html'), root);
271
- await writeFile(join(out, 'sitemap.xml'), sitemapXml([urls], defaultLanguage));
272
- return { languages: overview.languages, apps: apps.length };
273
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/site",
3
- "version": "0.64.11",
3
+ "version": "0.64.12-next.279",
4
4
  "description": "Static landing page generator for a GNOME-style app, rendered from its AppStream metainfo — downloads, screenshots, release notes, support links, no JavaScript required.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -54,25 +54,26 @@
54
54
  "license": "MIT",
55
55
  "dependencies": {
56
56
  "@girs/gdkpixbuf-2.0": "5.4.0",
57
- "@gjsify/console": "^0.64.11",
58
- "@gjsify/crypto": "^0.64.11",
59
- "@gjsify/fs": "^0.64.11",
60
- "@gjsify/module": "^0.64.11",
61
- "@gjsify/os": "^0.64.11",
62
- "@gjsify/path": "^0.64.11",
63
- "@gjsify/process": "^0.64.11",
64
- "@gjsify/url": "^0.64.11",
57
+ "@gjsify/console": "0.64.12-next.279",
58
+ "@gjsify/crypto": "0.64.12-next.279",
59
+ "@gjsify/fs": "0.64.12-next.279",
60
+ "@gjsify/module": "0.64.12-next.279",
61
+ "@gjsify/os": "0.64.12-next.279",
62
+ "@gjsify/path": "0.64.12-next.279",
63
+ "@gjsify/process": "0.64.12-next.279",
64
+ "@gjsify/url": "0.64.12-next.279",
65
65
  "fast-xml-parser": "^5.11.2",
66
66
  "qrcode-generator": "^2.0.4"
67
67
  },
68
68
  "devDependencies": {
69
- "@gjsify/cli": "^0.64.11",
69
+ "@gjsify/cli": "0.64.12-next.279",
70
70
  "@gjsify/node-gi": "file:../../node-gi/node-gi",
71
- "@gjsify/unit": "^0.64.11",
71
+ "@gjsify/unit": "0.64.12-next.279",
72
72
  "@types/node": "^25.9.2",
73
73
  "typescript": "^6.0.3"
74
74
  },
75
75
  "gjsify": {
76
76
  "tier": 2
77
- }
77
+ },
78
+ "gitHead": "e9026ad9f11b386102628e9e6fb93cb8b6cbd5ee"
78
79
  }