@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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
5
|
+
/** `YYYY-MM-DD` or an ISO 8601 date-time; anything else is a typo the build should name. */
|
|
6
|
+
function validateDate(date, file, key) {
|
|
7
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(date);
|
|
8
|
+
const dateTime = /^\d{4}-\d{2}-\d{2}T/.test(date);
|
|
9
|
+
if ((!dateOnly && !dateTime) || Number.isNaN(new Date(dateOnly ? `${date}T00:00:00Z` : date).getTime())) {
|
|
10
|
+
throw new Error(`${file}: ${key} "${date}" is not YYYY-MM-DD or an ISO 8601 date-time`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function isValidSlug(slug) {
|
|
14
|
+
return /^[a-z0-9][a-z0-9-]*$/.test(slug);
|
|
15
|
+
}
|
|
16
|
+
async function walkDir(dir, result) {
|
|
17
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const fullPath = join(dir, entry.name);
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
// Skip dotdirs
|
|
22
|
+
if (entry.name.startsWith('.')) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
await walkDir(fullPath, result);
|
|
26
|
+
}
|
|
27
|
+
else if (entry.isFile()) {
|
|
28
|
+
if (entry.name.startsWith('.')) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
result.push(fullPath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Every post under `dir` (recursive), newest first (ties: slug ascending, then lang). */
|
|
36
|
+
export async function loadBlogPosts(options) {
|
|
37
|
+
const { dir, languages, drafts } = options;
|
|
38
|
+
if (!existsSync(dir)) {
|
|
39
|
+
throw new Error(`blog.posts: ${dir} does not exist`);
|
|
40
|
+
}
|
|
41
|
+
const allFiles = [];
|
|
42
|
+
await walkDir(dir, allFiles);
|
|
43
|
+
const posts = [];
|
|
44
|
+
const seen = new Map(); // key slug+lang -> file
|
|
45
|
+
for (const file of allFiles) {
|
|
46
|
+
const basename = file.split(sep).pop() || file;
|
|
47
|
+
// skip README.md case-insensitively
|
|
48
|
+
if (basename.toLowerCase() === 'readme.md') {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!basename.endsWith('.md')) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (basename.startsWith('.')) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const content = await readFile(file, 'utf-8');
|
|
58
|
+
const parsed = parseFrontmatter(content, file);
|
|
59
|
+
// Determine slug and lang
|
|
60
|
+
let slug;
|
|
61
|
+
let lang;
|
|
62
|
+
// Try filename pattern <slug>.<lang>.md
|
|
63
|
+
const match = basename.match(/^(.+)\.([^.]+)\.md$/);
|
|
64
|
+
if (match) {
|
|
65
|
+
const maybeSlug = match[1];
|
|
66
|
+
const maybeLang = match[2];
|
|
67
|
+
if (languages.includes(maybeLang)) {
|
|
68
|
+
slug = maybeSlug;
|
|
69
|
+
lang = maybeLang;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// frontmatter overrides
|
|
73
|
+
if (parsed.data.slug !== undefined) {
|
|
74
|
+
if (typeof parsed.data.slug === 'string') {
|
|
75
|
+
slug = parsed.data.slug;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
throw new Error(`${file}: slug must be a string`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (parsed.data.lang !== undefined) {
|
|
82
|
+
if (typeof parsed.data.lang === 'string') {
|
|
83
|
+
lang = parsed.data.lang;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
throw new Error(`${file}: lang must be a string`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (lang === undefined || slug === undefined) {
|
|
90
|
+
throw new Error(`${file}: no language — name the file <slug>.<lang>.md or set "lang" in the frontmatter`);
|
|
91
|
+
}
|
|
92
|
+
if (!languages.includes(lang)) {
|
|
93
|
+
throw new Error(`${file}: lang ${lang} not in allowed list [${languages.join(', ')}]`);
|
|
94
|
+
}
|
|
95
|
+
if (!isValidSlug(slug)) {
|
|
96
|
+
throw new Error(`${file}: invalid slug ${slug}`);
|
|
97
|
+
}
|
|
98
|
+
// Check duplicates
|
|
99
|
+
const key = `${slug}\u0000${lang}`;
|
|
100
|
+
if (seen.has(key)) {
|
|
101
|
+
throw new Error(`${file}: duplicate slug+lang with ${seen.get(key)}`);
|
|
102
|
+
}
|
|
103
|
+
seen.set(key, file);
|
|
104
|
+
// Check unknown frontmatter keys
|
|
105
|
+
const allowedKeys = new Set(['slug', 'lang', 'title', 'date', 'updated', 'summary', 'tags', 'draft']);
|
|
106
|
+
for (const k of Object.keys(parsed.data)) {
|
|
107
|
+
if (!allowedKeys.has(k)) {
|
|
108
|
+
throw new Error(`${file}: unknown frontmatter key ${k}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Required
|
|
112
|
+
if (typeof parsed.data.title !== 'string') {
|
|
113
|
+
throw new Error(`${file}: title must be a string`);
|
|
114
|
+
}
|
|
115
|
+
if (typeof parsed.data.date !== 'string') {
|
|
116
|
+
throw new Error(`${file}: date must be a string`);
|
|
117
|
+
}
|
|
118
|
+
validateDate(parsed.data.date, file, 'date');
|
|
119
|
+
if (parsed.data.updated !== undefined) {
|
|
120
|
+
if (typeof parsed.data.updated !== 'string') {
|
|
121
|
+
throw new Error(`${file}: updated must be a string`);
|
|
122
|
+
}
|
|
123
|
+
validateDate(parsed.data.updated, file, 'updated');
|
|
124
|
+
}
|
|
125
|
+
if (parsed.data.summary !== undefined && typeof parsed.data.summary !== 'string') {
|
|
126
|
+
throw new Error(`${file}: summary must be a string`);
|
|
127
|
+
}
|
|
128
|
+
let tags = [];
|
|
129
|
+
if (parsed.data.tags !== undefined) {
|
|
130
|
+
if (typeof parsed.data.tags === 'string') {
|
|
131
|
+
tags = [parsed.data.tags];
|
|
132
|
+
}
|
|
133
|
+
else if (Array.isArray(parsed.data.tags)) {
|
|
134
|
+
tags = parsed.data.tags.map((t) => String(t));
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
throw new Error(`${file}: tags must be string[]`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let draft = false;
|
|
141
|
+
if (parsed.data.draft !== undefined) {
|
|
142
|
+
if (typeof parsed.data.draft === 'boolean') {
|
|
143
|
+
draft = parsed.data.draft;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
throw new Error(`${file}: draft must be boolean`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (draft && !drafts) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
posts.push({
|
|
153
|
+
slug,
|
|
154
|
+
lang,
|
|
155
|
+
title: parsed.data.title,
|
|
156
|
+
date: parsed.data.date,
|
|
157
|
+
updated: parsed.data.updated,
|
|
158
|
+
summary: parsed.data.summary,
|
|
159
|
+
tags,
|
|
160
|
+
draft,
|
|
161
|
+
body: parsed.body,
|
|
162
|
+
file,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// newest first (ties: slug ascending, then lang)
|
|
166
|
+
posts.sort((a, b) => {
|
|
167
|
+
const ta = postTime(a.date);
|
|
168
|
+
const tb = postTime(b.date);
|
|
169
|
+
if (tb !== ta) {
|
|
170
|
+
return tb - ta;
|
|
171
|
+
}
|
|
172
|
+
if (a.slug !== b.slug) {
|
|
173
|
+
return a.slug < b.slug ? -1 : 1;
|
|
174
|
+
}
|
|
175
|
+
if (a.lang !== b.lang) {
|
|
176
|
+
return a.lang < b.lang ? -1 : 1;
|
|
177
|
+
}
|
|
178
|
+
return 0;
|
|
179
|
+
});
|
|
180
|
+
return posts;
|
|
181
|
+
}
|
|
182
|
+
/** Posts with the same slug in the other languages. */
|
|
183
|
+
export function translationsOf(posts, post) {
|
|
184
|
+
return posts.filter((p) => p.slug === post.slug && p.lang !== post.lang);
|
|
185
|
+
}
|
|
186
|
+
/** Paths relative to dir (POSIX separators) of every non-.md file under dir, recursive, sorted. Skips dotfiles/dot-dirs. */
|
|
187
|
+
export async function blogAssetFiles(dir) {
|
|
188
|
+
const allFiles = [];
|
|
189
|
+
await walkDir(dir, allFiles);
|
|
190
|
+
const result = [];
|
|
191
|
+
for (const file of allFiles) {
|
|
192
|
+
const basename = file.split(sep).pop() || file;
|
|
193
|
+
if (basename.startsWith('.')) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (basename.endsWith('.md')) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const rel = relative(dir, file).split(sep).join('/');
|
|
200
|
+
result.push(rel);
|
|
201
|
+
}
|
|
202
|
+
result.sort();
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
/** URL-safe tag slug: lowercase, NFKD with diacritics removed, runs of non [a-z0-9] → '-', trimmed of '-'. Throws if the result is empty. */
|
|
206
|
+
export function tagSlug(tag) {
|
|
207
|
+
// NFKD normalization
|
|
208
|
+
const normalized = tag.normalize('NFKD');
|
|
209
|
+
// Remove diacritics
|
|
210
|
+
const withoutDiacritics = normalized.replace(/[\u0300-\u036f]/g, '');
|
|
211
|
+
const lower = withoutDiacritics.toLowerCase();
|
|
212
|
+
const slug = lower.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
213
|
+
if (slug === '') {
|
|
214
|
+
throw new Error('empty tag slug');
|
|
215
|
+
}
|
|
216
|
+
return slug;
|
|
217
|
+
}
|
|
218
|
+
/** Milliseconds since epoch for sorting/feeds; a date-only value means 00:00 UTC. */
|
|
219
|
+
export function postTime(date) {
|
|
220
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
221
|
+
const d = new Date(date + 'T00:00:00Z');
|
|
222
|
+
return d.getTime();
|
|
223
|
+
}
|
|
224
|
+
const d = new Date(date);
|
|
225
|
+
return d.getTime();
|
|
226
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SiteConfig } from './types.js';
|
|
1
|
+
import type { AnySiteConfig, SiteConfig } from './types.js';
|
|
2
2
|
/** The slice of the root package.json the site reads. Everything is optional until validated. */
|
|
3
3
|
export interface RootManifest {
|
|
4
4
|
version?: string;
|
|
@@ -19,7 +19,12 @@ export interface RootManifest {
|
|
|
19
19
|
}
|
|
20
20
|
/** Root-relative directory URL: `/x/` with a leading and trailing slash. */
|
|
21
21
|
export declare function normalizeBase(base: string): string;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Validates `gjsify.site` of either kind and applies defaults; the error names the offending key.
|
|
24
|
+
* The kind is the presence of `metainfo` (an app) or `developer` (a person) — exactly one of them.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseAnySiteConfig(manifest: RootManifest): AnySiteConfig;
|
|
27
|
+
/** Validates an app's `gjsify.site` and applies defaults; the error names the offending key. */
|
|
23
28
|
export declare function parseSiteConfig(manifest: RootManifest): SiteConfig;
|
|
24
29
|
/** The `kind` labels the support page knows by name; the page catalogues translate them. */
|
|
25
30
|
export declare const SUPPORT_KIND_LABELS: readonly ["GitHub Sponsors", "Liberapay", "PayPal"];
|
package/lib/site-data/config.js
CHANGED
|
@@ -60,14 +60,13 @@ function httpUrl(value, where) {
|
|
|
60
60
|
return value;
|
|
61
61
|
}
|
|
62
62
|
/** Strings are site addresses to fetch `app.json` from; objects are inline entries for apps without one. */
|
|
63
|
-
function parseRelatedApps(
|
|
64
|
-
const value = raw.relatedApps;
|
|
63
|
+
function parseRelatedApps(value, key) {
|
|
65
64
|
if (value === undefined)
|
|
66
65
|
return [];
|
|
67
66
|
if (!Array.isArray(value))
|
|
68
|
-
throw new Error(
|
|
67
|
+
throw new Error(`${key} must be a list`);
|
|
69
68
|
return value.map((entry, index) => {
|
|
70
|
-
const where =
|
|
69
|
+
const where = `${key}[${index}]`;
|
|
71
70
|
if (typeof entry === 'string')
|
|
72
71
|
return { url: withSlash(httpUrl(entry, where)) };
|
|
73
72
|
if (!isRecord(entry))
|
|
@@ -207,11 +206,60 @@ function parseReleaseAssets(raw) {
|
|
|
207
206
|
export function normalizeBase(base) {
|
|
208
207
|
return `/${base.replace(/^\/+|\/+$/g, '')}/`.replace(/^\/\/$/, '/');
|
|
209
208
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
209
|
+
function parseBlog(raw) {
|
|
210
|
+
const value = raw.blog;
|
|
211
|
+
if (value === undefined)
|
|
212
|
+
return undefined;
|
|
213
|
+
if (!isRecord(value))
|
|
214
|
+
throw new Error('gjsify.site.blog must be an object');
|
|
215
|
+
return { posts: stringField(value, 'posts', 'gjsify.site.blog') };
|
|
216
|
+
}
|
|
217
|
+
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
218
|
+
function parseColor(value, where) {
|
|
219
|
+
const hex = (entry, key) => {
|
|
220
|
+
if (typeof entry !== 'string' || !HEX_COLOR.test(entry))
|
|
221
|
+
throw new Error(`${key} must be a hex colour like #3584e4`);
|
|
222
|
+
return entry;
|
|
223
|
+
};
|
|
224
|
+
if (typeof value === 'string')
|
|
225
|
+
return { light: hex(value, where), dark: value };
|
|
226
|
+
if (!isRecord(value))
|
|
227
|
+
throw new Error(`${where} must be a hex colour or { "light", "dark" }`);
|
|
228
|
+
return { light: hex(value.light, `${where}.light`), dark: hex(value.dark, `${where}.dark`) };
|
|
229
|
+
}
|
|
230
|
+
function parseTheme(raw) {
|
|
231
|
+
const value = raw.theme;
|
|
232
|
+
if (value === undefined)
|
|
233
|
+
return { imageRendering: 'auto' };
|
|
234
|
+
if (!isRecord(value))
|
|
235
|
+
throw new Error('gjsify.site.theme must be an object');
|
|
236
|
+
const imageRendering = value.imageRendering ?? 'auto';
|
|
237
|
+
if (imageRendering !== 'auto' && imageRendering !== 'pixelated') {
|
|
238
|
+
throw new Error('gjsify.site.theme.imageRendering must be "auto" or "pixelated"');
|
|
239
|
+
}
|
|
240
|
+
const displayFont = value.displayFont === undefined ? undefined : stringField(value, 'displayFont', 'gjsify.site.theme');
|
|
241
|
+
if (displayFont !== undefined && !displayFont.endsWith('.woff2'))
|
|
242
|
+
throw new Error('gjsify.site.theme.displayFont must be a .woff2 file');
|
|
243
|
+
return {
|
|
244
|
+
accent: value.accent === undefined ? undefined : parseColor(value.accent, 'gjsify.site.theme.accent'),
|
|
245
|
+
displayFont,
|
|
246
|
+
imageRendering,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** One file per configured language, so no page of the CV silently falls back to another language. */
|
|
250
|
+
function parseDeveloper(value, languages) {
|
|
251
|
+
if (!isRecord(value))
|
|
252
|
+
throw new Error('gjsify.site.developer must be an object');
|
|
253
|
+
const resume = value.resume;
|
|
254
|
+
if (!isRecord(resume)) {
|
|
255
|
+
throw new Error('gjsify.site.developer.resume must be an object mapping a language to a JSON Resume file');
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
resume: Object.fromEntries(languages.map((lang) => [lang, stringField(resume, lang, 'gjsify.site.developer.resume')])),
|
|
259
|
+
apps: parseRelatedApps(value.apps, 'gjsify.site.developer.apps'),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function parseCommon(raw, base) {
|
|
215
263
|
const languages = raw.languages ?? ['en'];
|
|
216
264
|
if (!Array.isArray(languages) || languages.length === 0 || !languages.every((entry) => typeof entry === 'string')) {
|
|
217
265
|
throw new Error('gjsify.site.languages must be a non-empty list of language codes');
|
|
@@ -219,24 +267,14 @@ export function parseSiteConfig(manifest) {
|
|
|
219
267
|
const defaultLanguage = optionalString(raw, 'defaultLanguage') ?? languages[0];
|
|
220
268
|
if (!languages.includes(defaultLanguage))
|
|
221
269
|
throw new Error(`gjsify.site.defaultLanguage "${defaultLanguage}" is not in languages`);
|
|
222
|
-
const url = requireString(raw, 'url');
|
|
223
|
-
const components = parseComponents(raw);
|
|
270
|
+
const url = withSlash(requireString(raw, 'url'));
|
|
224
271
|
return {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
url: withSlash(url),
|
|
272
|
+
base: normalizeBase(base ?? requireString(raw, 'base')),
|
|
273
|
+
url,
|
|
228
274
|
languages: languages,
|
|
229
275
|
defaultLanguage,
|
|
230
276
|
sourceLanguage: optionalString(raw, 'sourceLanguage') ?? 'en',
|
|
231
|
-
repository: requireString(raw, 'repository'),
|
|
232
|
-
flathub: optionalString(raw, 'flathub'),
|
|
233
|
-
webApp: optionalString(raw, 'webApp'),
|
|
234
|
-
translations: optionalString(raw, 'translations'),
|
|
235
277
|
pageTranslations: optionalString(raw, 'pageTranslations'),
|
|
236
|
-
icon: optionalString(raw, 'icon'),
|
|
237
|
-
components,
|
|
238
|
-
downloads: parseDownloads(raw, components),
|
|
239
|
-
releaseAssets: parseReleaseAssets(raw),
|
|
240
278
|
support: objectList(raw, 'support').map((entry, index) => parseSupport(entry, `gjsify.site.support[${index}]`)),
|
|
241
279
|
privacy: parsePrivacy(raw, languages),
|
|
242
280
|
legalNotice: parseLegalNotice(raw, languages),
|
|
@@ -244,7 +282,64 @@ export function parseSiteConfig(manifest) {
|
|
|
244
282
|
label: stringField(entry, 'label', `gjsify.site.legal[${index}]`),
|
|
245
283
|
url: stringField(entry, 'url', `gjsify.site.legal[${index}]`),
|
|
246
284
|
})),
|
|
247
|
-
|
|
285
|
+
blog: parseBlog(raw),
|
|
286
|
+
theme: parseTheme(raw),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function siteBlock(manifest) {
|
|
290
|
+
const raw = manifest.gjsify?.site;
|
|
291
|
+
if (!isRecord(raw))
|
|
292
|
+
throw new Error('The root package.json has no "gjsify.site" object');
|
|
293
|
+
return raw;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Validates `gjsify.site` of either kind and applies defaults; the error names the offending key.
|
|
297
|
+
* The kind is the presence of `metainfo` (an app) or `developer` (a person) — exactly one of them.
|
|
298
|
+
*/
|
|
299
|
+
export function parseAnySiteConfig(manifest) {
|
|
300
|
+
const raw = siteBlock(manifest);
|
|
301
|
+
const isApp = raw.metainfo !== undefined;
|
|
302
|
+
const isDeveloper = raw.developer !== undefined;
|
|
303
|
+
if (isApp && isDeveloper) {
|
|
304
|
+
throw new Error('gjsify.site has both "metainfo" and "developer"; a site is about one app or one person');
|
|
305
|
+
}
|
|
306
|
+
if (!isApp && !isDeveloper) {
|
|
307
|
+
throw new Error('gjsify.site needs "metainfo" (an app site) or "developer" (a developer site)');
|
|
308
|
+
}
|
|
309
|
+
if (isApp)
|
|
310
|
+
return parseSiteConfig(manifest);
|
|
311
|
+
// The developer site usually owns the host root, so `base` may be left out there.
|
|
312
|
+
const common = parseCommon(raw, optionalString(raw, 'base') ?? '/');
|
|
313
|
+
if (raw.relatedApps !== undefined) {
|
|
314
|
+
throw new Error('gjsify.site.relatedApps is for app sites; a developer site lists apps in developer.apps');
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
kind: 'developer',
|
|
318
|
+
...common,
|
|
319
|
+
developer: parseDeveloper(raw.developer, common.languages),
|
|
320
|
+
repository: optionalString(raw, 'repository'),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** Validates an app's `gjsify.site` and applies defaults; the error names the offending key. */
|
|
324
|
+
export function parseSiteConfig(manifest) {
|
|
325
|
+
const raw = siteBlock(manifest);
|
|
326
|
+
if (raw.developer !== undefined)
|
|
327
|
+
throw new Error('gjsify.site describes a developer site, not an app');
|
|
328
|
+
const common = parseCommon(raw, undefined);
|
|
329
|
+
const components = parseComponents(raw);
|
|
330
|
+
return {
|
|
331
|
+
kind: 'app',
|
|
332
|
+
...common,
|
|
333
|
+
metainfo: requireString(raw, 'metainfo'),
|
|
334
|
+
repository: requireString(raw, 'repository'),
|
|
335
|
+
flathub: optionalString(raw, 'flathub'),
|
|
336
|
+
webApp: optionalString(raw, 'webApp'),
|
|
337
|
+
translations: optionalString(raw, 'translations'),
|
|
338
|
+
icon: optionalString(raw, 'icon'),
|
|
339
|
+
components,
|
|
340
|
+
downloads: parseDownloads(raw, components),
|
|
341
|
+
releaseAssets: parseReleaseAssets(raw),
|
|
342
|
+
relatedApps: parseRelatedApps(raw.relatedApps, 'gjsify.site.relatedApps'),
|
|
248
343
|
};
|
|
249
344
|
}
|
|
250
345
|
/** The `kind` labels the support page knows by name; the page catalogues translate them. */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type RelatedAppsOptions } from './related.js';
|
|
2
|
+
import { type Resume } from './resume.js';
|
|
3
|
+
import type { DeveloperSiteConfig, RelatedApp } from './types.js';
|
|
4
|
+
/** Everything a developer site renders that does not come from the blog. */
|
|
5
|
+
export interface DeveloperData {
|
|
6
|
+
config: DeveloperSiteConfig;
|
|
7
|
+
/** One résumé per configured language. */
|
|
8
|
+
resumes: Record<string, Resume>;
|
|
9
|
+
/** `developer.apps`, resolved; an app that cannot be had is left out with a warning. */
|
|
10
|
+
apps: RelatedApp[];
|
|
11
|
+
}
|
|
12
|
+
export interface LoadDeveloperOptions extends Omit<RelatedAppsOptions, 'warn'> {
|
|
13
|
+
root: string;
|
|
14
|
+
warn?: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function loadDeveloperData(config: DeveloperSiteConfig, options: LoadDeveloperOptions): Promise<DeveloperData>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { loadAppEntries } from './related.js';
|
|
3
|
+
import { loadResume } from './resume.js';
|
|
4
|
+
export async function loadDeveloperData(config, options) {
|
|
5
|
+
const entries = await Promise.all(config.languages.map(async (lang) => [lang, await loadResume(resolve(options.root, config.developer.resume[lang]))]));
|
|
6
|
+
const apps = await loadAppEntries(config.developer.apps, {
|
|
7
|
+
offline: options.offline,
|
|
8
|
+
network: options.network,
|
|
9
|
+
warn: options.warn ?? (() => { }),
|
|
10
|
+
});
|
|
11
|
+
return { config, resumes: Object.fromEntries(entries), apps };
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type FrontmatterValue = string | boolean | string[];
|
|
2
|
+
export interface Frontmatter {
|
|
3
|
+
data: Record<string, FrontmatterValue>;
|
|
4
|
+
body: string;
|
|
5
|
+
}
|
|
6
|
+
/** `file` is only used in error messages. Text without a leading `---` line has empty data and the whole text as body. */
|
|
7
|
+
export declare function parseFrontmatter(text: string, file: string): Frontmatter;
|