@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,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
- /** Validates `gjsify.site` and applies defaults; the error names the offending key. */
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"];
@@ -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(raw) {
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('gjsify.site.relatedApps must be a list');
67
+ throw new Error(`${key} must be a list`);
69
68
  return value.map((entry, index) => {
70
- const where = `gjsify.site.relatedApps[${index}]`;
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
- /** Validates `gjsify.site` and applies defaults; the error names the offending key. */
211
- export function parseSiteConfig(manifest) {
212
- const raw = manifest.gjsify?.site;
213
- if (!isRecord(raw))
214
- throw new Error('The root package.json has no "gjsify.site" object');
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
- metainfo: requireString(raw, 'metainfo'),
226
- base: normalizeBase(requireString(raw, 'base')),
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
- relatedApps: parseRelatedApps(raw),
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;