@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.
- 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,242 @@
|
|
|
1
|
+
function stripBom(text) {
|
|
2
|
+
if (text.charCodeAt(0) === 0xfeff) {
|
|
3
|
+
return text.slice(1);
|
|
4
|
+
}
|
|
5
|
+
return text;
|
|
6
|
+
}
|
|
7
|
+
function unescapeDoubleQuoted(value) {
|
|
8
|
+
let result = '';
|
|
9
|
+
for (let i = 0; i < value.length; i++) {
|
|
10
|
+
const ch = value[i];
|
|
11
|
+
if (ch === '\\' && i + 1 < value.length) {
|
|
12
|
+
const next = value[i + 1];
|
|
13
|
+
if (next === '"') {
|
|
14
|
+
result += '"';
|
|
15
|
+
i++;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (next === '\\') {
|
|
19
|
+
result += '\\';
|
|
20
|
+
i++;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (next === 'n') {
|
|
24
|
+
result += '\n';
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
// For other escapes, keep as-is per common YAML subset behavior? Spec says double supports \" \\ \n escapes
|
|
29
|
+
result += '\\' + next;
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
result += ch;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
function parseScalar(value, file, lineNumber) {
|
|
38
|
+
const trimmed = value.trim();
|
|
39
|
+
if (trimmed === '') {
|
|
40
|
+
// Will be handled by caller for dash lists
|
|
41
|
+
return trimmed;
|
|
42
|
+
}
|
|
43
|
+
if (trimmed === 'true') {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (trimmed === 'false') {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
50
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
51
|
+
if (inner === '') {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const result = [];
|
|
55
|
+
// Simple parser for inline lists
|
|
56
|
+
let current = '';
|
|
57
|
+
let inSingle = false;
|
|
58
|
+
let inDouble = false;
|
|
59
|
+
for (let i = 0; i < inner.length; i++) {
|
|
60
|
+
const ch = inner[i];
|
|
61
|
+
if (inDouble) {
|
|
62
|
+
if (ch === '\\' && i + 1 < inner.length) {
|
|
63
|
+
current += ch + inner[i + 1];
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ch === '"') {
|
|
68
|
+
inDouble = false;
|
|
69
|
+
current += ch;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
current += ch;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (inSingle) {
|
|
76
|
+
if (ch === "'" && i + 1 < inner.length && inner[i + 1] === "'") {
|
|
77
|
+
current += "''";
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === "'") {
|
|
82
|
+
inSingle = false;
|
|
83
|
+
current += ch;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
current += ch;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (ch === '"') {
|
|
90
|
+
inDouble = true;
|
|
91
|
+
current += ch;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === "'") {
|
|
95
|
+
inSingle = true;
|
|
96
|
+
current += ch;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ch === ',') {
|
|
100
|
+
result.push(parseScalar(current, file, lineNumber));
|
|
101
|
+
current = '';
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
current += ch;
|
|
105
|
+
}
|
|
106
|
+
if (current.trim() !== '') {
|
|
107
|
+
result.push(parseScalar(current, file, lineNumber));
|
|
108
|
+
}
|
|
109
|
+
// Ensure all are strings
|
|
110
|
+
return result.map((r) => String(r));
|
|
111
|
+
}
|
|
112
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
113
|
+
if (trimmed.length < 2) {
|
|
114
|
+
throw new Error(`${file}:${lineNumber}: unclosed quotes`);
|
|
115
|
+
}
|
|
116
|
+
// Check for unclosed
|
|
117
|
+
// Simple check - count unescaped quotes
|
|
118
|
+
const inner = trimmed.slice(1, -1);
|
|
119
|
+
// Validate escapes roughly
|
|
120
|
+
return unescapeDoubleQuoted(inner);
|
|
121
|
+
}
|
|
122
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
123
|
+
if (trimmed.length < 2) {
|
|
124
|
+
throw new Error(`${file}:${lineNumber}: unclosed quotes`);
|
|
125
|
+
}
|
|
126
|
+
const inner = trimmed.slice(1, -1);
|
|
127
|
+
// single supports ''
|
|
128
|
+
return inner.replace(/''/g, "'");
|
|
129
|
+
}
|
|
130
|
+
// Plain scalar
|
|
131
|
+
return trimmed;
|
|
132
|
+
}
|
|
133
|
+
/** `file` is only used in error messages. Text without a leading `---` line has empty data and the whole text as body. */
|
|
134
|
+
export function parseFrontmatter(text, file) {
|
|
135
|
+
let normalized = stripBom(text).replace(/\r\n/g, '\n');
|
|
136
|
+
if (!normalized.startsWith('---')) {
|
|
137
|
+
return { data: {}, body: text.replace(/\r\n/g, '\n') };
|
|
138
|
+
}
|
|
139
|
+
// Find closing ---
|
|
140
|
+
const lines = normalized.split('\n');
|
|
141
|
+
if (lines[0]?.trim() !== '---') {
|
|
142
|
+
return { data: {}, body: text.replace(/\r\n/g, '\n') };
|
|
143
|
+
}
|
|
144
|
+
let endIndex = -1;
|
|
145
|
+
for (let i = 1; i < lines.length; i++) {
|
|
146
|
+
if (lines[i]?.trim() === '---') {
|
|
147
|
+
endIndex = i;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (endIndex === -1) {
|
|
152
|
+
throw new Error(`${file}: missing closing ---`);
|
|
153
|
+
}
|
|
154
|
+
const data = {};
|
|
155
|
+
const seenKeys = new Set();
|
|
156
|
+
for (let i = 1; i < endIndex; i++) {
|
|
157
|
+
const line = lines[i];
|
|
158
|
+
const lineNumber = i + 1; // 1-based relative to start of file for error messages
|
|
159
|
+
// Skip blank lines and comments
|
|
160
|
+
const trimmed = line.trim();
|
|
161
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// Check for dash list item
|
|
165
|
+
if (line.match(/^\s*-\s+/)) {
|
|
166
|
+
// This would mean we're in a list - but we need to track context
|
|
167
|
+
// For our subset, dash list comes after empty value
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const colonIndex = line.indexOf(':');
|
|
171
|
+
if (colonIndex === -1) {
|
|
172
|
+
throw new Error(`${file}:${lineNumber}: invalid line`);
|
|
173
|
+
}
|
|
174
|
+
const key = line.slice(0, colonIndex).trim();
|
|
175
|
+
if (key === '') {
|
|
176
|
+
throw new Error(`${file}:${lineNumber}: invalid key`);
|
|
177
|
+
}
|
|
178
|
+
if (seenKeys.has(key)) {
|
|
179
|
+
throw new Error(`${file}:${lineNumber}: duplicate key ${key}`);
|
|
180
|
+
}
|
|
181
|
+
const valuePart = line.slice(colonIndex + 1);
|
|
182
|
+
// Check if empty value followed by dash list
|
|
183
|
+
if (valuePart.trim() === '') {
|
|
184
|
+
// Look ahead for dash list items
|
|
185
|
+
const items = [];
|
|
186
|
+
let j = i + 1;
|
|
187
|
+
while (j < endIndex) {
|
|
188
|
+
const nextLine = lines[j];
|
|
189
|
+
const nextTrimmed = nextLine.trim();
|
|
190
|
+
if (nextTrimmed === '' || nextTrimmed.startsWith('#')) {
|
|
191
|
+
j++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (nextLine.match(/^\s*-\s+/)) {
|
|
195
|
+
const itemContent = nextLine.replace(/^\s*-\s+/, '').trim();
|
|
196
|
+
// Item could be quoted or plain
|
|
197
|
+
if (itemContent.startsWith('"') && itemContent.endsWith('"')) {
|
|
198
|
+
items.push(unescapeDoubleQuoted(itemContent.slice(1, -1)));
|
|
199
|
+
}
|
|
200
|
+
else if (itemContent.startsWith("'") && itemContent.endsWith("'")) {
|
|
201
|
+
items.push(itemContent.slice(1, -1).replace(/''/g, "'"));
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
items.push(itemContent);
|
|
205
|
+
}
|
|
206
|
+
j++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
if (items.length > 0) {
|
|
212
|
+
data[key] = items;
|
|
213
|
+
seenKeys.add(key);
|
|
214
|
+
i = j - 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
// Empty value with nothing after - could be error or just empty? But spec says "empty value followed by dash list lines" gives string[]
|
|
219
|
+
// If no dash list follows, maybe treat as empty string or error? Let us look up - but be conservative
|
|
220
|
+
throw new Error(`${file}:${lineNumber}: invalid value`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Parse scalar value
|
|
224
|
+
// Numbers and dates stay strings: the post decides what they mean.
|
|
225
|
+
data[key] = parseScalar(valuePart, file, lineNumber);
|
|
226
|
+
seenKeys.add(key);
|
|
227
|
+
}
|
|
228
|
+
// Body = everything after the closing delimiter with ONE leading newline removed
|
|
229
|
+
let body = '';
|
|
230
|
+
if (endIndex + 1 < lines.length) {
|
|
231
|
+
const bodyLines = lines.slice(endIndex + 1);
|
|
232
|
+
body = bodyLines.join('\n');
|
|
233
|
+
// Remove ONE leading newline if present
|
|
234
|
+
if (body.startsWith('\n')) {
|
|
235
|
+
body = body.slice(1);
|
|
236
|
+
}
|
|
237
|
+
else if (body.startsWith('\r\n')) {
|
|
238
|
+
body = body.slice(2);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { data, body };
|
|
242
|
+
}
|
package/lib/site-data/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
export * from './blog.js';
|
|
1
2
|
export * from './config.js';
|
|
3
|
+
export * from './developer.js';
|
|
2
4
|
export * from './downloads.js';
|
|
3
5
|
export * from './flathub.js';
|
|
6
|
+
export * from './frontmatter.js';
|
|
4
7
|
export * from './github.js';
|
|
5
8
|
export * from './load.js';
|
|
6
9
|
export * from './metainfo.js';
|
|
@@ -10,6 +13,7 @@ export * from './oars.js';
|
|
|
10
13
|
export * from './po.js';
|
|
11
14
|
export * from './privacy.js';
|
|
12
15
|
export * from './related.js';
|
|
16
|
+
export * from './resume.js';
|
|
13
17
|
export * from './screenshots.js';
|
|
14
18
|
export * from './sepa.js';
|
|
15
19
|
export * from './types.js';
|
package/lib/site-data/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
export * from './blog.js';
|
|
1
2
|
export * from './config.js';
|
|
3
|
+
export * from './developer.js';
|
|
2
4
|
export * from './downloads.js';
|
|
3
5
|
export * from './flathub.js';
|
|
6
|
+
export * from './frontmatter.js';
|
|
4
7
|
export * from './github.js';
|
|
5
8
|
export * from './load.js';
|
|
6
9
|
export * from './metainfo.js';
|
|
@@ -10,6 +13,7 @@ export * from './oars.js';
|
|
|
10
13
|
export * from './po.js';
|
|
11
14
|
export * from './privacy.js';
|
|
12
15
|
export * from './related.js';
|
|
16
|
+
export * from './resume.js';
|
|
13
17
|
export * from './screenshots.js';
|
|
14
18
|
export * from './sepa.js';
|
|
15
19
|
export * from './types.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type NetworkOptions } from './network.js';
|
|
2
|
-
import type { AppManifest, RelatedApp, SiteConfig, SiteData } from './types.js';
|
|
2
|
+
import type { AppManifest, RelatedApp, SiteConfig, SiteData, SiteRelatedAppConfig } from './types.js';
|
|
3
3
|
/** Where a site publishes its `app.json`. */
|
|
4
4
|
export declare const APP_MANIFEST_FILE = "app.json";
|
|
5
5
|
/** The `app.json` of a site: what another site needs to list this app. Icon URL is absolute. */
|
|
@@ -12,9 +12,11 @@ export interface RelatedAppsOptions {
|
|
|
12
12
|
network?: NetworkOptions;
|
|
13
13
|
warn: (message: string) => void;
|
|
14
14
|
}
|
|
15
|
+
/** Resolves app entries in order — `relatedApps`, or a developer's `apps`. Never throws: an entry that cannot be had is skipped with a warning. */
|
|
16
|
+
export declare function loadAppEntries(entries: readonly SiteRelatedAppConfig[], options: RelatedAppsOptions): Promise<RelatedApp[]>;
|
|
15
17
|
/**
|
|
16
18
|
* Resolves `gjsify.site.relatedApps` in configured order. The site's own entry is dropped, so every
|
|
17
|
-
* app can share one list.
|
|
19
|
+
* app can share one list.
|
|
18
20
|
*/
|
|
19
21
|
export declare function loadRelatedApps(config: SiteConfig, ownId: string, options: RelatedAppsOptions): Promise<RelatedApp[]>;
|
|
20
22
|
/** A text in the reader's language, else English, else whatever the app has. */
|
package/lib/site-data/related.js
CHANGED
|
@@ -81,14 +81,19 @@ async function loadOne(entry, options) {
|
|
|
81
81
|
return undefined;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
/** Resolves app entries in order — `relatedApps`, or a developer's `apps`. Never throws: an entry that cannot be had is skipped with a warning. */
|
|
85
|
+
export async function loadAppEntries(entries, options) {
|
|
86
|
+
const loaded = await Promise.all(entries.map((entry) => loadOne(entry, options)));
|
|
87
|
+
return loaded.filter((app) => app !== undefined);
|
|
88
|
+
}
|
|
84
89
|
/**
|
|
85
90
|
* Resolves `gjsify.site.relatedApps` in configured order. The site's own entry is dropped, so every
|
|
86
|
-
* app can share one list.
|
|
91
|
+
* app can share one list.
|
|
87
92
|
*/
|
|
88
93
|
export async function loadRelatedApps(config, ownId, options) {
|
|
89
94
|
const candidates = config.relatedApps.filter((entry) => !sameUrl(entry.url, config.url));
|
|
90
|
-
const loaded = await
|
|
91
|
-
return loaded.filter((app) => app
|
|
95
|
+
const loaded = await loadAppEntries(candidates, options);
|
|
96
|
+
return loaded.filter((app) => app.id !== ownId && !sameUrl(app.url, config.url));
|
|
92
97
|
}
|
|
93
98
|
/** A text in the reader's language, else English, else whatever the app has. */
|
|
94
99
|
export function relatedText(text, lang) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface ResumeProfile {
|
|
2
|
+
network: string;
|
|
3
|
+
username?: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ResumeBasics {
|
|
7
|
+
name: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
image?: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
summary?: string;
|
|
13
|
+
location?: {
|
|
14
|
+
city?: string;
|
|
15
|
+
region?: string;
|
|
16
|
+
countryCode?: string;
|
|
17
|
+
};
|
|
18
|
+
profiles: ResumeProfile[];
|
|
19
|
+
}
|
|
20
|
+
export interface ResumeWork {
|
|
21
|
+
name: string;
|
|
22
|
+
position?: string;
|
|
23
|
+
url?: string;
|
|
24
|
+
startDate?: string;
|
|
25
|
+
endDate?: string;
|
|
26
|
+
summary?: string;
|
|
27
|
+
highlights: string[];
|
|
28
|
+
location?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ResumeEducation {
|
|
31
|
+
institution: string;
|
|
32
|
+
area?: string;
|
|
33
|
+
studyType?: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
startDate?: string;
|
|
36
|
+
endDate?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ResumeSkill {
|
|
39
|
+
name: string;
|
|
40
|
+
keywords: string[];
|
|
41
|
+
}
|
|
42
|
+
export interface ResumeLanguage {
|
|
43
|
+
language: string;
|
|
44
|
+
fluency?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ResumeProject {
|
|
47
|
+
name: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
url?: string;
|
|
50
|
+
startDate?: string;
|
|
51
|
+
endDate?: string;
|
|
52
|
+
highlights: string[];
|
|
53
|
+
keywords: string[];
|
|
54
|
+
}
|
|
55
|
+
export interface Resume {
|
|
56
|
+
basics: ResumeBasics;
|
|
57
|
+
work: ResumeWork[];
|
|
58
|
+
education: ResumeEducation[];
|
|
59
|
+
skills: ResumeSkill[];
|
|
60
|
+
languages: ResumeLanguage[];
|
|
61
|
+
projects: ResumeProject[];
|
|
62
|
+
}
|
|
63
|
+
export declare function parseResume(raw: unknown, file: string): Resume;
|
|
64
|
+
export declare function loadResume(path: string): Promise<Resume>;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
const isRecord = (value) => {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
};
|
|
5
|
+
function validateDateString(value, file, path) {
|
|
6
|
+
if (value === undefined || value === null) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
if (typeof value !== 'string') {
|
|
10
|
+
throw new Error(`${file}: ${path} must be a string`);
|
|
11
|
+
}
|
|
12
|
+
// Empty strings for optional fields are treated as absent
|
|
13
|
+
if (value === '') {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
// JSON-Resume form (YYYY, YYYY-MM or YYYY-MM-DD)
|
|
17
|
+
if (/^\d{4}$/.test(value) || /^\d{4}-\d{2}$/.test(value) || /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`${file}: ${path} "${value}" is not YYYY, YYYY-MM or YYYY-MM-DD`);
|
|
21
|
+
}
|
|
22
|
+
function validateString(value, file, path) {
|
|
23
|
+
if (typeof value !== 'string') {
|
|
24
|
+
throw new Error(`${file}: ${path} must be a string`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function validateOptionalString(value, file, path) {
|
|
29
|
+
if (value === undefined || value === null) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
if (typeof value !== 'string') {
|
|
33
|
+
throw new Error(`${file}: ${path} must be a string`);
|
|
34
|
+
}
|
|
35
|
+
if (value === '') {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
export function parseResume(raw, file) {
|
|
41
|
+
if (!isRecord(raw)) {
|
|
42
|
+
throw new Error(`${file}: invalid resume`);
|
|
43
|
+
}
|
|
44
|
+
if (!isRecord(raw.basics)) {
|
|
45
|
+
throw new Error(`${file}: basics must be an object`);
|
|
46
|
+
}
|
|
47
|
+
const basicsRaw = raw.basics;
|
|
48
|
+
const basicsName = validateString(basicsRaw.name, file, 'basics.name');
|
|
49
|
+
const basics = {
|
|
50
|
+
name: basicsName,
|
|
51
|
+
label: validateOptionalString(basicsRaw.label, file, 'basics.label'),
|
|
52
|
+
image: validateOptionalString(basicsRaw.image, file, 'basics.image'),
|
|
53
|
+
email: validateOptionalString(basicsRaw.email, file, 'basics.email'),
|
|
54
|
+
url: validateOptionalString(basicsRaw.url, file, 'basics.url'),
|
|
55
|
+
summary: validateOptionalString(basicsRaw.summary, file, 'basics.summary'),
|
|
56
|
+
location: undefined,
|
|
57
|
+
profiles: [],
|
|
58
|
+
};
|
|
59
|
+
if (basicsRaw.location !== undefined && basicsRaw.location !== null) {
|
|
60
|
+
if (!isRecord(basicsRaw.location)) {
|
|
61
|
+
throw new Error(`${file}: basics.location must be an object`);
|
|
62
|
+
}
|
|
63
|
+
const loc = basicsRaw.location;
|
|
64
|
+
basics.location = {
|
|
65
|
+
city: validateOptionalString(loc.city, file, 'basics.location.city'),
|
|
66
|
+
region: validateOptionalString(loc.region, file, 'basics.location.region'),
|
|
67
|
+
countryCode: validateOptionalString(loc.countryCode, file, 'basics.location.countryCode'),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (basicsRaw.profiles !== undefined && basicsRaw.profiles !== null) {
|
|
71
|
+
if (!Array.isArray(basicsRaw.profiles)) {
|
|
72
|
+
throw new Error(`${file}: basics.profiles must be an array`);
|
|
73
|
+
}
|
|
74
|
+
for (let i = 0; i < basicsRaw.profiles.length; i++) {
|
|
75
|
+
const p = basicsRaw.profiles[i];
|
|
76
|
+
if (!isRecord(p)) {
|
|
77
|
+
throw new Error(`${file}: basics.profiles[${i}] must be an object`);
|
|
78
|
+
}
|
|
79
|
+
if (typeof p.network !== 'string') {
|
|
80
|
+
throw new Error(`${file}: basics.profiles[${i}].network must be a string`);
|
|
81
|
+
}
|
|
82
|
+
basics.profiles.push({
|
|
83
|
+
network: p.network,
|
|
84
|
+
username: validateOptionalString(p.username, file, `basics.profiles[${i}].username`),
|
|
85
|
+
url: validateOptionalString(p.url, file, `basics.profiles[${i}].url`),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
basics.profiles = [];
|
|
91
|
+
}
|
|
92
|
+
const work = [];
|
|
93
|
+
if (raw.work !== undefined && raw.work !== null) {
|
|
94
|
+
if (!Array.isArray(raw.work)) {
|
|
95
|
+
throw new Error(`${file}: work must be an array`);
|
|
96
|
+
}
|
|
97
|
+
for (let i = 0; i < raw.work.length; i++) {
|
|
98
|
+
const w = raw.work[i];
|
|
99
|
+
if (!isRecord(w)) {
|
|
100
|
+
throw new Error(`${file}: work[${i}] must be an object`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof w.name !== 'string') {
|
|
103
|
+
throw new Error(`${file}: work[${i}].name must be a string`);
|
|
104
|
+
}
|
|
105
|
+
const highlights = [];
|
|
106
|
+
if (w.highlights !== undefined && w.highlights !== null) {
|
|
107
|
+
if (!Array.isArray(w.highlights)) {
|
|
108
|
+
throw new Error(`${file}: work[${i}].highlights must be an array`);
|
|
109
|
+
}
|
|
110
|
+
for (const h of w.highlights) {
|
|
111
|
+
if (typeof h !== 'string') {
|
|
112
|
+
throw new Error(`${file}: work[${i}].highlights must contain strings`);
|
|
113
|
+
}
|
|
114
|
+
highlights.push(h);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
work.push({
|
|
118
|
+
name: w.name,
|
|
119
|
+
position: validateOptionalString(w.position, file, `work[${i}].position`),
|
|
120
|
+
url: validateOptionalString(w.url, file, `work[${i}].url`),
|
|
121
|
+
startDate: validateDateString(w.startDate, file, `work[${i}].startDate`),
|
|
122
|
+
endDate: validateDateString(w.endDate, file, `work[${i}].endDate`),
|
|
123
|
+
summary: validateOptionalString(w.summary, file, `work[${i}].summary`),
|
|
124
|
+
highlights,
|
|
125
|
+
location: validateOptionalString(w.location, file, `work[${i}].location`),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const education = [];
|
|
130
|
+
if (raw.education !== undefined && raw.education !== null) {
|
|
131
|
+
if (!Array.isArray(raw.education)) {
|
|
132
|
+
throw new Error(`${file}: education must be an array`);
|
|
133
|
+
}
|
|
134
|
+
for (let i = 0; i < raw.education.length; i++) {
|
|
135
|
+
const e = raw.education[i];
|
|
136
|
+
if (!isRecord(e)) {
|
|
137
|
+
throw new Error(`${file}: education[${i}] must be an object`);
|
|
138
|
+
}
|
|
139
|
+
if (typeof e.institution !== 'string') {
|
|
140
|
+
throw new Error(`${file}: education[${i}].institution must be a string`);
|
|
141
|
+
}
|
|
142
|
+
education.push({
|
|
143
|
+
institution: e.institution,
|
|
144
|
+
area: validateOptionalString(e.area, file, `education[${i}].area`),
|
|
145
|
+
studyType: validateOptionalString(e.studyType, file, `education[${i}].studyType`),
|
|
146
|
+
url: validateOptionalString(e.url, file, `education[${i}].url`),
|
|
147
|
+
startDate: validateDateString(e.startDate, file, `education[${i}].startDate`),
|
|
148
|
+
endDate: validateDateString(e.endDate, file, `education[${i}].endDate`),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const skills = [];
|
|
153
|
+
if (raw.skills !== undefined && raw.skills !== null) {
|
|
154
|
+
if (!Array.isArray(raw.skills)) {
|
|
155
|
+
throw new Error(`${file}: skills must be an array`);
|
|
156
|
+
}
|
|
157
|
+
for (let i = 0; i < raw.skills.length; i++) {
|
|
158
|
+
const s = raw.skills[i];
|
|
159
|
+
if (!isRecord(s)) {
|
|
160
|
+
throw new Error(`${file}: skills[${i}] must be an object`);
|
|
161
|
+
}
|
|
162
|
+
if (typeof s.name !== 'string') {
|
|
163
|
+
throw new Error(`${file}: skills[${i}].name must be a string`);
|
|
164
|
+
}
|
|
165
|
+
const keywords = [];
|
|
166
|
+
if (s.keywords !== undefined && s.keywords !== null) {
|
|
167
|
+
if (!Array.isArray(s.keywords)) {
|
|
168
|
+
throw new Error(`${file}: skills[${i}].keywords must be an array`);
|
|
169
|
+
}
|
|
170
|
+
for (const k of s.keywords) {
|
|
171
|
+
if (typeof k !== 'string') {
|
|
172
|
+
throw new Error(`${file}: skills[${i}].keywords must contain strings`);
|
|
173
|
+
}
|
|
174
|
+
keywords.push(k);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
skills.push({
|
|
178
|
+
name: s.name,
|
|
179
|
+
keywords,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const languages = [];
|
|
184
|
+
if (raw.languages !== undefined && raw.languages !== null) {
|
|
185
|
+
if (!Array.isArray(raw.languages)) {
|
|
186
|
+
throw new Error(`${file}: languages must be an array`);
|
|
187
|
+
}
|
|
188
|
+
for (let i = 0; i < raw.languages.length; i++) {
|
|
189
|
+
const l = raw.languages[i];
|
|
190
|
+
if (!isRecord(l)) {
|
|
191
|
+
throw new Error(`${file}: languages[${i}] must be an object`);
|
|
192
|
+
}
|
|
193
|
+
if (typeof l.language !== 'string') {
|
|
194
|
+
throw new Error(`${file}: languages[${i}].language must be a string`);
|
|
195
|
+
}
|
|
196
|
+
languages.push({
|
|
197
|
+
language: l.language,
|
|
198
|
+
fluency: validateOptionalString(l.fluency, file, `languages[${i}].fluency`),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const projects = [];
|
|
203
|
+
if (raw.projects !== undefined && raw.projects !== null) {
|
|
204
|
+
if (!Array.isArray(raw.projects)) {
|
|
205
|
+
throw new Error(`${file}: projects must be an array`);
|
|
206
|
+
}
|
|
207
|
+
for (let i = 0; i < raw.projects.length; i++) {
|
|
208
|
+
const pr = raw.projects[i];
|
|
209
|
+
if (!isRecord(pr)) {
|
|
210
|
+
throw new Error(`${file}: projects[${i}] must be an object`);
|
|
211
|
+
}
|
|
212
|
+
if (typeof pr.name !== 'string') {
|
|
213
|
+
throw new Error(`${file}: projects[${i}].name must be a string`);
|
|
214
|
+
}
|
|
215
|
+
const highlights = [];
|
|
216
|
+
if (pr.highlights !== undefined && pr.highlights !== null) {
|
|
217
|
+
if (!Array.isArray(pr.highlights)) {
|
|
218
|
+
throw new Error(`${file}: projects[${i}].highlights must be an array`);
|
|
219
|
+
}
|
|
220
|
+
for (const h of pr.highlights) {
|
|
221
|
+
if (typeof h !== 'string') {
|
|
222
|
+
throw new Error(`${file}: projects[${i}].highlights must contain strings`);
|
|
223
|
+
}
|
|
224
|
+
highlights.push(h);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const keywords = [];
|
|
228
|
+
if (pr.keywords !== undefined && pr.keywords !== null) {
|
|
229
|
+
if (!Array.isArray(pr.keywords)) {
|
|
230
|
+
throw new Error(`${file}: projects[${i}].keywords must be an array`);
|
|
231
|
+
}
|
|
232
|
+
for (const k of pr.keywords) {
|
|
233
|
+
if (typeof k !== 'string') {
|
|
234
|
+
throw new Error(`${file}: projects[${i}].keywords must contain strings`);
|
|
235
|
+
}
|
|
236
|
+
keywords.push(k);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
projects.push({
|
|
240
|
+
name: pr.name,
|
|
241
|
+
description: validateOptionalString(pr.description, file, `projects[${i}].description`),
|
|
242
|
+
url: validateOptionalString(pr.url, file, `projects[${i}].url`),
|
|
243
|
+
startDate: validateDateString(pr.startDate, file, `projects[${i}].startDate`),
|
|
244
|
+
endDate: validateDateString(pr.endDate, file, `projects[${i}].endDate`),
|
|
245
|
+
highlights,
|
|
246
|
+
keywords,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
basics,
|
|
252
|
+
work,
|
|
253
|
+
education,
|
|
254
|
+
skills,
|
|
255
|
+
languages,
|
|
256
|
+
projects,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
export async function loadResume(path) {
|
|
260
|
+
try {
|
|
261
|
+
const content = await readFile(path, 'utf-8');
|
|
262
|
+
let raw;
|
|
263
|
+
try {
|
|
264
|
+
raw = JSON.parse(content);
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
throw new Error(`${path}: ${String(e)}`);
|
|
268
|
+
}
|
|
269
|
+
return parseResume(raw, path);
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
if (e instanceof Error) {
|
|
273
|
+
throw e;
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`${path}: ${String(e)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|