jsonresume-theme-signal 1.0.0

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/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "jsonresume-theme-signal",
3
+ "version": "1.0.0",
4
+ "description": "High-signal, print-ready JSON Resume theme with multi-role company grouping and career partitioning",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "module": "index.js",
8
+ "types": "index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "bun": "./src/index.ts",
13
+ "import": "./index.js",
14
+ "default": "./index.js"
15
+ },
16
+ "./types.ts": "./src/types.ts"
17
+ },
18
+ "files": ["index.js", "index.d.ts", "src/", "README.md"],
19
+ "keywords": ["jsonresume", "jsonresume-theme", "resume", "cv", "pdf"],
20
+ "author": "Meni Livne",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/menil/jsonresume-theme-signal.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/menil/jsonresume-theme-signal/issues"
28
+ },
29
+ "homepage": "https://github.com/menil/jsonresume-theme-signal#readme",
30
+ "scripts": {
31
+ "build": "bun build src/index.ts --outfile index.js --target node --format esm --packages=bundle && bun run scripts/build-declarations.ts",
32
+ "prepublishOnly": "bun run build",
33
+ "prepack": "bun run build",
34
+ "test": "bun test",
35
+ "typecheck": "tsc --noEmit",
36
+ "lint": "biome check .",
37
+ "format": "biome format --write .",
38
+ "check-format": "biome format ."
39
+ },
40
+ "trustedDependencies": ["@biomejs/biome"],
41
+ "dependencies": {
42
+ "handlebars": "^4.7.8"
43
+ },
44
+ "devDependencies": {
45
+ "@biomejs/biome": "^1.9.4",
46
+ "@types/bun": "^1.3.13",
47
+ "@types/handlebars": "^4.1.0",
48
+ "puppeteer": "^24.4.0",
49
+ "resumed": "^7.0.0",
50
+ "typescript": "^5.7.3"
51
+ }
52
+ }
@@ -0,0 +1,9 @@
1
+ declare module "*.hbs" {
2
+ const content: string;
3
+ export default content;
4
+ }
5
+
6
+ declare module "*.css" {
7
+ const content: string;
8
+ export default content;
9
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,196 @@
1
+ import type {
2
+ EarlyCareerItem,
3
+ PreparedResumeData,
4
+ ResumeData,
5
+ ResumeProfile,
6
+ ResumeWork,
7
+ ThemeFitOptions,
8
+ ThemeOptions,
9
+ } from "./types.ts";
10
+
11
+ /**
12
+ * Extracts a profile URL matching a given network name (case-insensitive).
13
+ */
14
+ export function getProfile(profiles: ResumeProfile[] | undefined, network: string): string {
15
+ if (!profiles || !Array.isArray(profiles)) return "";
16
+ const match = profiles.find((p) => p.network?.toLowerCase() === network.toLowerCase());
17
+ return match?.url || "";
18
+ }
19
+
20
+ export function getLinkedIn(profiles: ResumeProfile[] | undefined): string {
21
+ return getProfile(profiles, "linkedin");
22
+ }
23
+
24
+ export function getGitHub(profiles: ResumeProfile[] | undefined): string {
25
+ return getProfile(profiles, "github");
26
+ }
27
+
28
+ /**
29
+ * Extracts the 4-digit ending year from a date string.
30
+ */
31
+ export function getEndYear(dateStr?: string): number | null {
32
+ if (!dateStr || typeof dateStr !== "string") return null;
33
+ const match = dateStr.match(/\b(19\d{2}|20\d{2})\b/);
34
+ return match ? Number.parseInt(match[1], 10) : null;
35
+ }
36
+
37
+ /**
38
+ * Formats start and end dates cleanly (e.g., "2020 - Present", "2018 - 2020").
39
+ * Note: Uses standard hyphen instead of en-dash to avoid font glyph stretching
40
+ * and excessive spacing under bold weights in PDF print renderers.
41
+ */
42
+ export function formatDates(startDate?: string, endDate?: string): string {
43
+ if (startDate && endDate) {
44
+ return `${startDate} - ${endDate}`;
45
+ }
46
+ if (startDate && !endDate) {
47
+ return `${startDate} - Present`;
48
+ }
49
+ if (!startDate && endDate) {
50
+ return endDate;
51
+ }
52
+ return "";
53
+ }
54
+
55
+ /**
56
+ * Strips protocol and leading 'www.' from URLs for clean print rendering.
57
+ */
58
+ export function stripUrl(url?: string): string {
59
+ if (!url) return "";
60
+ return url.replace(/^https?:\/\//, "").replace(/^www\./, "");
61
+ }
62
+
63
+ /**
64
+ * Groups consecutive roles at the same company together.
65
+ */
66
+ export function groupWork(workList: ResumeWork[]): ResumeWork[] {
67
+ if (!workList || workList.length === 0) return [];
68
+
69
+ const grouped: ResumeWork[] = [];
70
+
71
+ for (const item of workList) {
72
+ const role = {
73
+ position: item.position || "",
74
+ startDate: item.startDate,
75
+ endDate: item.endDate,
76
+ dates: formatDates(item.startDate, item.endDate),
77
+ summary: item.summary,
78
+ highlights: item.highlights || [],
79
+ };
80
+
81
+ const prev = grouped[grouped.length - 1];
82
+ if (prev?.name && item.name && prev.name.trim() === item.name.trim()) {
83
+ prev.roles = prev.roles || [];
84
+ prev.roles.push(role);
85
+ } else {
86
+ grouped.push({
87
+ name: item.name,
88
+ url: item.url,
89
+ description: item.description || item.summary,
90
+ roles: [role],
91
+ });
92
+ }
93
+ }
94
+
95
+ return grouped;
96
+ }
97
+
98
+ /**
99
+ * Prepares resume data:
100
+ * - Partitions work experience older than 10 years into early career summary items.
101
+ * - Extracts social links into basics.
102
+ * - Groups multiple roles under the same company.
103
+ */
104
+ export function prepareResume(
105
+ data: ResumeData,
106
+ optionsOrAsOfYear?: ThemeOptions | number,
107
+ ): PreparedResumeData {
108
+ const options: ThemeOptions =
109
+ typeof optionsOrAsOfYear === "number"
110
+ ? { asOfYear: optionsOrAsOfYear }
111
+ : (optionsOrAsOfYear ?? {});
112
+
113
+ const metaOptions = (data.meta?.themeOptions as ThemeFitOptions | undefined) || {};
114
+
115
+ const currentYear = options.asOfYear ?? new Date().getFullYear();
116
+ const cutoffYear = currentYear - 10;
117
+
118
+ const rawWork = data.work || [];
119
+ const professionalWork: ResumeWork[] = [];
120
+ const earlyCareerList: EarlyCareerItem[] = [];
121
+
122
+ for (const w of rawWork) {
123
+ const endYr = getEndYear(w.endDate);
124
+ const isOngoing = !w.endDate;
125
+ const isEarly = !isOngoing && endYr !== null && endYr < cutoffYear;
126
+
127
+ if (isEarly) {
128
+ const dates = formatDates(w.startDate, w.endDate);
129
+ const company = w.name || "";
130
+ const position = w.position || "";
131
+ const details = position && company ? `${position}, ${company}` : position || company;
132
+
133
+ earlyCareerList.push({
134
+ dates,
135
+ details,
136
+ });
137
+ } else {
138
+ professionalWork.push(w);
139
+ }
140
+ }
141
+
142
+ const groupedWork = groupWork(professionalWork);
143
+
144
+ const basics = data.basics ? { ...data.basics } : {};
145
+ if (!basics.linkedin) {
146
+ basics.linkedin = getLinkedIn(basics.profiles);
147
+ }
148
+ if (!basics.github) {
149
+ basics.github = getGitHub(basics.profiles);
150
+ }
151
+ if (!basics.pdf_url && basics.name) {
152
+ const slug = basics.name.toLowerCase().replace(/[^a-z0-9_-]+/g, "_");
153
+ basics.pdf_url = `${slug}_resume.pdf`;
154
+ }
155
+
156
+ const fit_pages = options.fitPages ?? metaOptions.fitPages ?? "off";
157
+ const fit_tolerance = options.fitTolerance ?? metaOptions.fitTolerance ?? 0.28;
158
+ const density = options.density ?? metaOptions.density ?? "normal";
159
+
160
+ return {
161
+ ...data,
162
+ basics,
163
+ work: groupedWork,
164
+ early_career: earlyCareerList.length > 0 ? earlyCareerList : data.early_career,
165
+ fit_pages,
166
+ fit_tolerance,
167
+ density,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Registers all custom helpers onto a Handlebars instance.
173
+ */
174
+ export function registerHelpers(hbs: typeof import("handlebars")): void {
175
+ hbs.registerHelper("formatDates", (startDate: string, endDate: string) =>
176
+ formatDates(startDate, endDate),
177
+ );
178
+ hbs.registerHelper("stripUrl", (url: string) => stripUrl(url));
179
+ hbs.registerHelper("join", (arr: string[] | undefined, separator: string) => {
180
+ if (!Array.isArray(arr)) return "";
181
+ return arr.join(typeof separator === "string" ? separator : ", ");
182
+ });
183
+ hbs.registerHelper("eq", (a: unknown, b: unknown) => a === b);
184
+ hbs.registerHelper("contains", (str: unknown, substr: unknown) => {
185
+ if (typeof str !== "string" || typeof substr !== "string") return false;
186
+ return str.includes(substr);
187
+ });
188
+ hbs.registerHelper("or", (...args: unknown[]) => {
189
+ const items = args.slice(0, -1);
190
+ return items.some((item) => Boolean(item));
191
+ });
192
+ hbs.registerHelper("and", (...args: unknown[]) => {
193
+ const items = args.slice(0, -1);
194
+ return items.every((item) => Boolean(item));
195
+ });
196
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import Handlebars from "handlebars";
5
+ import { prepareResume, registerHelpers } from "./helpers.ts";
6
+ import styleCss from "./style.css" with { type: "text" };
7
+ import templateSource from "./template.hbs" with { type: "text" };
8
+ import type { ResumeData, ThemeOptions } from "./types.ts";
9
+
10
+ const hbs = Handlebars.create();
11
+ registerHelpers(hbs);
12
+
13
+ const compiledTemplate = hbs.compile(templateSource);
14
+
15
+ /**
16
+ * Default Puppeteer PDF rendering options for the resumed CLI.
17
+ */
18
+ export const pdfRenderOptions = {
19
+ format: "Letter",
20
+ printBackground: true,
21
+ preferCSSPageSize: true,
22
+ } as const;
23
+
24
+ /**
25
+ * Renders a JSON Resume object to an HTML string.
26
+ */
27
+ export function render(resume: ResumeData = {}, options: ThemeOptions = {}): string {
28
+ const prepared = prepareResume(resume, options);
29
+ const metaOptions = (resume.meta?.themeOptions || {}) as ThemeOptions;
30
+ prepared.is_pdf = options.is_pdf ?? metaOptions.is_pdf ?? false;
31
+
32
+ return compiledTemplate({
33
+ ...prepared,
34
+ css: styleCss,
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Builds an HTML file from a JSON Resume JSON file on disk.
40
+ */
41
+ export function buildHtml(
42
+ inputJsonPath: string,
43
+ outputPath?: string,
44
+ options: ThemeOptions = {},
45
+ ): string {
46
+ let data: ResumeData;
47
+ try {
48
+ const raw = readFileSync(inputJsonPath, "utf-8");
49
+ data = JSON.parse(raw);
50
+ } catch (err) {
51
+ throw new Error(
52
+ `Failed to read or parse JSON Resume file at "${inputJsonPath}": ${err instanceof Error ? err.message : String(err)}`,
53
+ );
54
+ }
55
+
56
+ let targetPath = outputPath;
57
+ if (!targetPath) {
58
+ const name = data.basics?.name || "resume";
59
+ const slug = name.toLowerCase().replace(/[^a-z0-9_-]+/g, "_");
60
+ targetPath = join(dirname(inputJsonPath), `${slug}_resume.html`);
61
+ }
62
+
63
+ mkdirSync(dirname(targetPath), { recursive: true });
64
+ const html = render(data, options);
65
+ writeFileSync(targetPath, html, "utf-8");
66
+ return targetPath;
67
+ }
68
+
69
+ // CLI usage: bun src/index.ts <input.json> [output.html]
70
+ const isCliEntrypoint =
71
+ typeof process !== "undefined" &&
72
+ process.argv[1] !== undefined &&
73
+ fileURLToPath(import.meta.url) === process.argv[1];
74
+
75
+ if (isCliEntrypoint) {
76
+ const args = process.argv.slice(2);
77
+ if (args.length === 0) {
78
+ console.error("Usage: bun src/index.ts <input.json> [output.html]");
79
+ process.exit(1);
80
+ }
81
+
82
+ const inputFile = args[0];
83
+ const outputFile = args[1];
84
+
85
+ try {
86
+ const createdPath = buildHtml(inputFile, outputFile);
87
+ console.log(`Success! Created ${createdPath}`);
88
+ } catch (err) {
89
+ console.error("Error generating HTML:", err);
90
+ process.exit(1);
91
+ }
92
+ }