careermate 0.0.6 → 0.0.8

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 (69) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +30 -10
  3. package/THIRD_PARTY_NOTICES.md +2 -1
  4. package/dist/career-os/README.md +4 -3
  5. package/dist/career-os/eop/career-description.md +3 -0
  6. package/dist/career-os/eop/cover-letter.md +8 -4
  7. package/dist/career-os/eop/fit-analysis.md +1 -1
  8. package/dist/career-os/eop/job-analysis.md +1 -1
  9. package/dist/career-os/knowledge/cover-letter.md +4 -3
  10. package/dist/career-os/knowledge/fit-matching.md +5 -3
  11. package/dist/career-os/knowledge/human-writing.md +2 -0
  12. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  13. package/dist/career-os/knowledge/verifiers/ats-compat.md +1 -1
  14. package/dist/career-os/knowledge/verifiers/human-voice.md +2 -0
  15. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +1 -1
  16. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  17. package/dist/doctor.mjs +19012 -809
  18. package/dist/mcp.mjs +19027 -824
  19. package/dist/migrate.mjs +83 -6
  20. package/dist/public/i18n/en/common.js +4 -0
  21. package/dist/public/i18n/en/documents.js +0 -10
  22. package/dist/public/i18n/en/home.js +5 -0
  23. package/dist/public/i18n/en/jobs.js +1 -1
  24. package/dist/public/i18n/en/profile.js +0 -5
  25. package/dist/public/i18n/ko/common.js +4 -0
  26. package/dist/public/i18n/ko/documents.js +0 -10
  27. package/dist/public/i18n/ko/home.js +5 -0
  28. package/dist/public/i18n/ko/jobs.js +1 -1
  29. package/dist/public/i18n/ko/profile.js +0 -5
  30. package/dist/public/lib.js +72 -0
  31. package/dist/public/pages/applications.js +10 -6
  32. package/dist/public/pages/documents.js +4 -7
  33. package/dist/public/pages/home.js +26 -6
  34. package/dist/public/pages/profile.js +2 -3
  35. package/dist/public/styles.css +39 -4
  36. package/dist/web.mjs +18548 -649
  37. package/docs/MCP_TOOLS.md +19 -10
  38. package/docs/START_WORKFLOW.md +1 -1
  39. package/docs/career-os/README.md +4 -3
  40. package/docs/career-os/eop/career-description.md +3 -0
  41. package/docs/career-os/eop/cover-letter.md +8 -4
  42. package/docs/career-os/eop/fit-analysis.md +1 -1
  43. package/docs/career-os/eop/job-analysis.md +1 -1
  44. package/docs/career-os/knowledge/cover-letter.md +4 -3
  45. package/docs/career-os/knowledge/fit-matching.md +5 -3
  46. package/docs/career-os/knowledge/human-writing.md +2 -0
  47. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  48. package/docs/career-os/knowledge/verifiers/ats-compat.md +1 -1
  49. package/docs/career-os/knowledge/verifiers/human-voice.md +2 -0
  50. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +1 -1
  51. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  52. package/package.json +3 -2
  53. package/packages/core/src/index.ts +1 -0
  54. package/packages/core/src/jobsearch.ts +186 -0
  55. package/packages/core/src/services.ts +24 -2
  56. package/packages/core/src/summary.ts +196 -0
  57. package/packages/core/src/verify/charcount.ts +87 -0
  58. package/packages/core/src/verify/index.ts +1 -0
  59. package/packages/core/src/verify/lint.ts +5 -0
  60. package/packages/db/src/repositories.ts +42 -8
  61. package/packages/db/src/schema.ts +29 -0
  62. package/packages/exporters/src/docx.ts +165 -0
  63. package/packages/exporters/src/index.ts +142 -9
  64. package/packages/exporters/src/markdown.ts +16 -11
  65. package/packages/knowledge/src/index.ts +17 -5
  66. package/packages/mcp-tools/src/tools.ts +181 -59
  67. package/packages/prompts/src/system.ts +1 -0
  68. package/packages/shared/src/schemas.ts +64 -0
  69. package/scripts/init.ts +5 -0
@@ -64,6 +64,10 @@ function mapProfile(r: any): ProfileRecord {
64
64
  preferred_tone: r.preferred_tone,
65
65
  emphasis_points: fromJson(r.emphasis_points, []),
66
66
  links: fromJson(r.links, []),
67
+ education: fromJson(r.education, []),
68
+ certifications: fromJson(r.certifications, []),
69
+ language_scores: fromJson(r.language_scores, []),
70
+ awards: fromJson(r.awards, []),
67
71
  created_at: r.created_at,
68
72
  updated_at: r.updated_at,
69
73
  };
@@ -92,21 +96,31 @@ export const profileRepo = {
92
96
  preferred_tone: input.preferred_tone ?? existing?.preferred_tone ?? null,
93
97
  emphasis_points: input.emphasis_points ?? existing?.emphasis_points ?? [],
94
98
  links: input.links ?? existing?.links ?? [],
99
+ education: input.education ?? existing?.education ?? [],
100
+ certifications: input.certifications ?? existing?.certifications ?? [],
101
+ language_scores: input.language_scores ?? existing?.language_scores ?? [],
102
+ awards: input.awards ?? existing?.awards ?? [],
95
103
  created_at: existing?.created_at ?? ts,
96
104
  updated_at: ts,
97
105
  };
98
106
  db.prepare(
99
- `INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,created_at,updated_at)
100
- VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@created_at,@updated_at)
107
+ `INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,education,certifications,language_scores,awards,created_at,updated_at)
108
+ VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@education,@certifications,@language_scores,@awards,@created_at,@updated_at)
101
109
  ON CONFLICT(id) DO UPDATE SET
102
110
  name=@name,email=@email,phone=@phone,location=@location,headline=@headline,summary=@summary,
103
111
  desired_roles=@desired_roles,desired_conditions=@desired_conditions,preferred_tone=@preferred_tone,
104
- emphasis_points=@emphasis_points,links=@links,updated_at=@updated_at`,
112
+ emphasis_points=@emphasis_points,links=@links,
113
+ education=@education,certifications=@certifications,language_scores=@language_scores,awards=@awards,
114
+ updated_at=@updated_at`,
105
115
  ).run({
106
116
  ...merged,
107
117
  desired_roles: toJson(merged.desired_roles),
108
118
  emphasis_points: toJson(merged.emphasis_points),
109
119
  links: toJson(merged.links),
120
+ education: toJson(merged.education),
121
+ certifications: toJson(merged.certifications),
122
+ language_scores: toJson(merged.language_scores),
123
+ awards: toJson(merged.awards),
110
124
  });
111
125
  return merged;
112
126
  },
@@ -642,6 +656,9 @@ function mapJob(r: any): JobRecord {
642
656
  keywords: fromJson(r.keywords, []),
643
657
  deadline: r.deadline,
644
658
  source: r.source,
659
+ company_overview: r.company_overview ?? null,
660
+ talent_profile: r.talent_profile ?? null,
661
+ core_values: fromJson(r.core_values, []),
645
662
  created_at: r.created_at,
646
663
  updated_at: r.updated_at,
647
664
  };
@@ -674,7 +691,7 @@ export const jobRepo = {
674
691
  if (existing) {
675
692
  const m = { ...existing, ...input } as JobRecord;
676
693
  db.prepare(
677
- `UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,updated_at=? WHERE id=?`,
694
+ `UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,company_overview=?,talent_profile=?,core_values=?,updated_at=? WHERE id=?`,
678
695
  ).run(
679
696
  m.company,
680
697
  m.position,
@@ -686,6 +703,9 @@ export const jobRepo = {
686
703
  toJson(m.keywords ?? []),
687
704
  m.deadline ?? null,
688
705
  m.source ?? null,
706
+ m.company_overview ?? null,
707
+ m.talent_profile ?? null,
708
+ toJson(m.core_values ?? []),
689
709
  ts,
690
710
  existing.id,
691
711
  );
@@ -693,8 +713,8 @@ export const jobRepo = {
693
713
  }
694
714
  const newJobId = id ?? newId('job_');
695
715
  db.prepare(
696
- `INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,created_at,updated_at)
697
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
716
+ `INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,company_overview,talent_profile,core_values,created_at,updated_at)
717
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
698
718
  ).run(
699
719
  newJobId,
700
720
  input.company,
@@ -707,6 +727,9 @@ export const jobRepo = {
707
727
  toJson(input.keywords ?? []),
708
728
  input.deadline ?? null,
709
729
  input.source ?? null,
730
+ input.company_overview ?? null,
731
+ input.talent_profile ?? null,
732
+ toJson(input.core_values ?? []),
710
733
  ts,
711
734
  ts,
712
735
  );
@@ -872,6 +895,9 @@ export const applicationRepo = {
872
895
  /* --------------------------------------------------------- Interview preps */
873
896
 
874
897
  function mapInterviewPrep(r: any): InterviewPrepRecord {
898
+ // debrief defaults to '{}' at the DB level; surface an empty debrief as null so
899
+ // consumers can treat "no debrief yet" uniformly.
900
+ const debrief = fromJson(r.debrief, null);
875
901
  return {
876
902
  id: r.id,
877
903
  job_id: r.job_id,
@@ -879,6 +905,7 @@ function mapInterviewPrep(r: any): InterviewPrepRecord {
879
905
  star_guides: fromJson(r.star_guides, []),
880
906
  self_introduction: r.self_introduction,
881
907
  notes: r.notes,
908
+ debrief: debrief && Object.keys(debrief).length ? debrief : null,
882
909
  created_at: r.created_at,
883
910
  updated_at: r.updated_at,
884
911
  };
@@ -900,13 +927,19 @@ export const interviewRepo = {
900
927
  const ts = now();
901
928
  if (existing) {
902
929
  const m = { ...existing, ...input } as InterviewPrepRecord;
930
+ // Debrief is shallow-merged (not replaced) so a later partial debrief save
931
+ // doesn't wipe earlier fields; prep (pre) and debrief (post) coexist per job.
932
+ const mergedDebrief = input.debrief
933
+ ? { ...(existing.debrief ?? {}), ...input.debrief }
934
+ : existing.debrief;
903
935
  db.prepare(
904
- `UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,updated_at=? WHERE id=?`,
936
+ `UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,debrief=?,updated_at=? WHERE id=?`,
905
937
  ).run(
906
938
  toJson(m.questions ?? []),
907
939
  toJson(m.star_guides ?? []),
908
940
  m.self_introduction ?? null,
909
941
  m.notes ?? null,
942
+ toJson(mergedDebrief ?? {}),
910
943
  ts,
911
944
  existing.id,
912
945
  );
@@ -914,7 +947,7 @@ export const interviewRepo = {
914
947
  }
915
948
  const id = newId('itv_');
916
949
  db.prepare(
917
- `INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`,
950
+ `INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,debrief,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
918
951
  ).run(
919
952
  id,
920
953
  input.job_id,
@@ -922,6 +955,7 @@ export const interviewRepo = {
922
955
  toJson(input.star_guides ?? []),
923
956
  input.self_introduction ?? null,
924
957
  input.notes ?? null,
958
+ toJson(input.debrief ?? {}),
925
959
  ts,
926
960
  ts,
927
961
  );
@@ -300,6 +300,35 @@ export const MIGRATIONS: string[] = [
300
300
 
301
301
  CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
302
302
  `,
303
+
304
+ // v4 — structured quantitative credentials on the profile (학력·자격증·어학점수·수상).
305
+ // Stored as JSON arrays in TEXT columns (same pattern as profile.links), so the AI
306
+ // can cite exact values (GPA, TOEIC 920) without re-asking or hallucinating, and
307
+ // they feed the verify corpus. ALTER ADD COLUMN is non-destructive and idempotent
308
+ // across the ordered migration runner.
309
+ `
310
+ ALTER TABLE profile ADD COLUMN education TEXT NOT NULL DEFAULT '[]';
311
+ ALTER TABLE profile ADD COLUMN certifications TEXT NOT NULL DEFAULT '[]';
312
+ ALTER TABLE profile ADD COLUMN language_scores TEXT NOT NULL DEFAULT '[]';
313
+ ALTER TABLE profile ADD COLUMN awards TEXT NOT NULL DEFAULT '[]';
314
+ `,
315
+
316
+ // v5 — company research on the job posting (사업개요·인재상·핵심가치). Korea-centric: cover
317
+ // letters and interview prep align to a company's 인재상/핵심가치, so store it where the AI
318
+ // already reads it (the job context) rather than a separate entity. Additive ALTER, idempotent.
319
+ `
320
+ ALTER TABLE jobs ADD COLUMN company_overview TEXT;
321
+ ALTER TABLE jobs ADD COLUMN talent_profile TEXT;
322
+ ALTER TABLE jobs ADD COLUMN core_values TEXT NOT NULL DEFAULT '[]';
323
+ `,
324
+
325
+ // v6 — post-interview debrief on interview_preps (받은 질문·잘된 점·개선점·다음 라운드 포커스·
326
+ // 체감 분위기). Closes the loop: prep is pre-interview, debrief is post-interview, but both belong
327
+ // to the same per-job entity, so store as one JSON column rather than a new table. Additive ALTER,
328
+ // idempotent. Feeds next-round prep and consistency (lessons carry into later interviews).
329
+ `
330
+ ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
331
+ `,
303
332
  ];
304
333
 
305
334
  export function migrate(db: DatabaseSync): { from: number; to: number } {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Markdown → .docx renderer.
3
+ *
4
+ * The other exporters already produce a small, well-known Markdown dialect
5
+ * (headings `#`…`####`, `- ` bullets, `**label**: value` rows, `[text](url)`
6
+ * links, `**bold**`, `_italic_`, `---` rules). Rather than re-derive structure
7
+ * from records per document type, we render THAT dialect to a Word document —
8
+ * exactly mirroring how `html.ts` turns the same Markdown into print HTML. One
9
+ * faithful, ATS-safe single-column layout; no decorative template.
10
+ *
11
+ * Binary output is a `Uint8Array` (the OOXML zip). Callers stream/write it as-is.
12
+ */
13
+ import {
14
+ Document,
15
+ ExternalHyperlink,
16
+ HeadingLevel,
17
+ Packer,
18
+ Paragraph,
19
+ TextRun,
20
+ } from 'docx';
21
+
22
+ const HEADING_BY_LEVEL = [
23
+ HeadingLevel.HEADING_1,
24
+ HeadingLevel.HEADING_2,
25
+ HeadingLevel.HEADING_3,
26
+ HeadingLevel.HEADING_4,
27
+ ] as const;
28
+
29
+ /**
30
+ * XML 1.0 forbids these control chars even as numeric references; the `docx`
31
+ * lib writes run text verbatim into document.xml, so a single one makes the
32
+ * whole .docx not well-formed and Word refuses to open it. Tab (0x09) and
33
+ * newline (0x0A) are valid and preserved. Strip everything else in the C0 set.
34
+ */
35
+ const XML_INVALID_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
36
+ const xmlSafe = (s: string): string => s.replace(XML_INVALID_RE, '');
37
+
38
+ const ALNUM_RE = /[\p{L}\p{N}]/u;
39
+ /** Link-scheme allowlist — mirrors html.ts renderInline so the docx path can't
40
+ * emit javascript:/file:// hyperlinks that the HTML path neutralizes. */
41
+ const SAFE_LINK_RE = /^(https?:|mailto:|#|\/)/i;
42
+
43
+ /** Inline tokens: **bold**, _italic_, [text](url). Order-preserving, non-nesting. */
44
+ const INLINE_RE = /(\*\*([^*]+)\*\*|_([^_]+)_|\[([^\]]+)\]\(([^)]+)\))/g;
45
+
46
+ function inlineRuns(text: string): (TextRun | ExternalHyperlink)[] {
47
+ const src = xmlSafe(text);
48
+ const out: (TextRun | ExternalHyperlink)[] = [];
49
+ let last = 0;
50
+ for (const m of src.matchAll(INLINE_RE)) {
51
+ const idx = m.index ?? 0;
52
+ if (idx > last) out.push(new TextRun(src.slice(last, idx)));
53
+ if (m[2] !== undefined) {
54
+ out.push(new TextRun({ text: m[2], bold: true }));
55
+ } else if (m[3] !== undefined) {
56
+ // CommonMark intraword-underscore rule: `_` is emphasis only when flanked
57
+ // by non-alphanumerics, so snake_case / env-vars / file_paths
58
+ // (read_write_latency, API_KEY) are kept literal instead of being mangled.
59
+ const before = idx > 0 ? src[idx - 1] : '';
60
+ const after = src[idx + m[0].length] ?? '';
61
+ if (ALNUM_RE.test(before) || ALNUM_RE.test(after)) {
62
+ out.push(new TextRun(m[0])); // not emphasis — emit underscores literally
63
+ } else {
64
+ out.push(new TextRun({ text: m[3], italics: true }));
65
+ }
66
+ } else if (m[4] !== undefined) {
67
+ // [text](url) — a real Word hyperlink so it survives ATS text extraction.
68
+ const link = (m[5] ?? '').trim();
69
+ if (SAFE_LINK_RE.test(link)) {
70
+ out.push(
71
+ new ExternalHyperlink({
72
+ children: [new TextRun({ text: m[4], style: 'Hyperlink' })],
73
+ link,
74
+ }),
75
+ );
76
+ } else {
77
+ // Disallowed scheme (javascript:/file:/data:) → plain text, mirroring html.ts.
78
+ out.push(new TextRun(m[4]));
79
+ }
80
+ }
81
+ last = idx + m[0].length;
82
+ }
83
+ if (last < src.length) out.push(new TextRun(src.slice(last)));
84
+ return out.length ? out : [new TextRun('')];
85
+ }
86
+
87
+ /** Parse our Markdown dialect (blocks split on blank lines) into docx paragraphs. */
88
+ function markdownToParagraphs(markdown: string): Paragraph[] {
89
+ const paras: Paragraph[] = [];
90
+ const lines = markdown.replace(/\r\n/g, '\n').split('\n');
91
+
92
+ for (const raw of lines) {
93
+ const line = raw.trimEnd();
94
+ if (line.trim() === '') continue; // blank lines: spacing comes from paragraph config
95
+
96
+ // Horizontal rule → a thin bottom border on an empty paragraph.
97
+ if (/^---+$/.test(line.trim())) {
98
+ paras.push(
99
+ new Paragraph({
100
+ border: { bottom: { style: 'single', size: 6, space: 1, color: 'CCCCCC' } },
101
+ spacing: { after: 160 },
102
+ children: [new TextRun('')],
103
+ }),
104
+ );
105
+ continue;
106
+ }
107
+
108
+ // ATX heading.
109
+ const h = /^(#{1,4})\s+(.*)$/.exec(line);
110
+ if (h) {
111
+ const level = h[1].length - 1;
112
+ paras.push(
113
+ new Paragraph({
114
+ heading: HEADING_BY_LEVEL[level],
115
+ spacing: { before: level === 0 ? 0 : 200, after: 80 },
116
+ children: inlineRuns(h[2]),
117
+ }),
118
+ );
119
+ continue;
120
+ }
121
+
122
+ // Bullet (-, *, + — match the markdown dialect html.ts accepts).
123
+ const b = /^[-*+]\s+(.*)$/.exec(line);
124
+ if (b) {
125
+ paras.push(
126
+ new Paragraph({ bullet: { level: 0 }, spacing: { after: 40 }, children: inlineRuns(b[1]) }),
127
+ );
128
+ continue;
129
+ }
130
+
131
+ paras.push(new Paragraph({ spacing: { after: 120 }, children: inlineRuns(line) }));
132
+ }
133
+
134
+ return paras;
135
+ }
136
+
137
+ const DOCX_FONT = '맑은 고딕'; // Malgun Gothic — ships with Windows, renders Korean + Latin cleanly.
138
+
139
+ /**
140
+ * Render a title + our Markdown body to a .docx byte array (single-column,
141
+ * ATS-friendly). The leading `# title` in the body is rendered as Heading 1, so
142
+ * we don't duplicate the title here.
143
+ */
144
+ export async function markdownToDocx(_title: string, markdown: string): Promise<Uint8Array> {
145
+ const doc = new Document({
146
+ styles: {
147
+ default: {
148
+ document: { run: { font: DOCX_FONT, size: 22 } }, // 11pt
149
+ },
150
+ },
151
+ sections: [
152
+ {
153
+ properties: {
154
+ page: { margin: { top: 1134, bottom: 1134, left: 1134, right: 1134 } }, // 2cm
155
+ },
156
+ children: markdownToParagraphs(markdown),
157
+ },
158
+ ],
159
+ });
160
+ // Packer.toBuffer returns a Node Buffer (a Uint8Array subclass) in Node.
161
+ return Packer.toBuffer(doc);
162
+ }
163
+
164
+ export const DOCX_MIME =
165
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
@@ -28,6 +28,7 @@ import {
28
28
  slugify,
29
29
  } from './markdown.ts';
30
30
  import { escapeHtml, toPrintableHtml } from './html.ts';
31
+ import { markdownToDocx, DOCX_MIME } from './docx.ts';
31
32
 
32
33
  /** The uniform shape every exporter returns. */
33
34
  export interface ExportResult {
@@ -35,8 +36,16 @@ export interface ExportResult {
35
36
  filename: string;
36
37
  /** MIME type, e.g. `text/markdown` or `text/html`. */
37
38
  mimeType: string;
38
- /** The document body. */
39
+ /**
40
+ * The document body as text. For binary formats (`.docx`) this is a short,
41
+ * human-readable note instead of the payload — write/stream {@link bytes}.
42
+ */
39
43
  content: string;
44
+ /**
45
+ * Present only for binary formats (e.g. `.docx`). When set, callers MUST
46
+ * write/stream these bytes rather than {@link content}.
47
+ */
48
+ bytes?: Uint8Array;
40
49
  }
41
50
 
42
51
  export type ExportOptions = {
@@ -47,6 +56,23 @@ export type ExportOptions = {
47
56
  const MD_MIME = 'text/markdown';
48
57
  const HTML_MIME = 'text/html';
49
58
 
59
+ /** Stand-in `content` for binary exports — the real payload rides in `bytes`. */
60
+ function docxNote(filename: string): string {
61
+ return `(Word 문서로 저장됨: ${filename})`;
62
+ }
63
+
64
+ /**
65
+ * Compose an export filename from slugged parts joined by `_`, then the
66
+ * extension — e.g. `["토스 백엔드 자기소개서", "홍길동"]` → `토스_백엔드_자기소개서_홍길동.docx`.
67
+ * Empty parts (no title, no owner name) drop out; if nothing survives, `fallback`
68
+ * is used. The owner's name as the trailing part makes files self-identifying in
69
+ * a downloads folder.
70
+ */
71
+ function exportName(parts: (string | null | undefined)[], ext: string, fallback: string): string {
72
+ const slug = parts.map((p) => slugify(p, '')).filter(Boolean).join('_') || fallback;
73
+ return `${slug}.${ext}`;
74
+ }
75
+
50
76
  /* ------------------------------------------------------------- cover letters */
51
77
 
52
78
  /** Build the shared markdown body for a cover letter (used by MD + HTML). */
@@ -74,7 +100,7 @@ export function coverLetterToMarkdown(
74
100
  opts?: ExportOptions,
75
101
  ): ExportResult {
76
102
  return {
77
- filename: `${slugify(cl.title, 'cover-letter')}.md`,
103
+ filename: exportName([cl.title, opts?.profile?.name], 'md', 'cover_letter'),
78
104
  mimeType: MD_MIME,
79
105
  content: coverLetterBody(cl, opts),
80
106
  };
@@ -86,12 +112,22 @@ export function coverLetterToHtml(
86
112
  opts?: ExportOptions,
87
113
  ): ExportResult {
88
114
  return {
89
- filename: `${slugify(cl.title, 'cover-letter')}.html`,
115
+ filename: exportName([cl.title, opts?.profile?.name], 'html', 'cover_letter'),
90
116
  mimeType: HTML_MIME,
91
117
  content: toPrintableHtml(cl.title, coverLetterBody(cl, opts)),
92
118
  };
93
119
  }
94
120
 
121
+ /** Cover letter → Word (.docx), ATS-friendly single column. */
122
+ export async function coverLetterToDocx(
123
+ cl: CoverLetterRecord,
124
+ opts?: ExportOptions,
125
+ ): Promise<ExportResult> {
126
+ const filename = exportName([cl.title, opts?.profile?.name], 'docx', 'cover_letter');
127
+ const bytes = await markdownToDocx(cl.title, coverLetterBody(cl, opts));
128
+ return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
129
+ }
130
+
95
131
  /* -------------------------------------------------------------------- resume */
96
132
 
97
133
  /** A stored resume/career document → Markdown. */
@@ -115,7 +151,7 @@ export function resumeToMarkdown(
115
151
  ]);
116
152
 
117
153
  return {
118
- filename: `${slugify(doc.title, doc.kind)}.md`,
154
+ filename: exportName([doc.title, profile?.name], 'md', doc.kind),
119
155
  mimeType: MD_MIME,
120
156
  content: body,
121
157
  };
@@ -128,12 +164,23 @@ export function resumeToHtml(
128
164
  ): ExportResult {
129
165
  const md = resumeToMarkdown(doc, profile);
130
166
  return {
131
- filename: `${slugify(doc.title, doc.kind)}.html`,
167
+ filename: exportName([doc.title, profile?.name], 'html', doc.kind),
132
168
  mimeType: HTML_MIME,
133
169
  content: toPrintableHtml(doc.title, md.content),
134
170
  };
135
171
  }
136
172
 
173
+ /** A stored resume/career document → Word (.docx). */
174
+ export async function resumeToDocx(
175
+ doc: DocumentRecord,
176
+ profile?: ProfileRecord | null,
177
+ ): Promise<ExportResult> {
178
+ const md = resumeToMarkdown(doc, profile);
179
+ const filename = exportName([doc.title, profile?.name], 'docx', doc.kind);
180
+ const bytes = await markdownToDocx(doc.title, md.content);
181
+ return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
182
+ }
183
+
137
184
  /* ------------------------------------------------------------------- profile */
138
185
 
139
186
  /** Full resume-style profile export → Markdown. */
@@ -243,7 +290,7 @@ export function profileToMarkdown(
243
290
  ]);
244
291
 
245
292
  return {
246
- filename: `${slugify(name, 'resume')}-resume.md`,
293
+ filename: exportName(['프로필', profile.name], 'md', 'resume'),
247
294
  mimeType: MD_MIME,
248
295
  content,
249
296
  };
@@ -259,12 +306,97 @@ export function profileToHtml(
259
306
  const md = profileToMarkdown(profile, experiences, projects, skills);
260
307
  const title = profile.name?.trim() || '이력서';
261
308
  return {
262
- filename: `${slugify(title, 'resume')}-resume.html`,
309
+ filename: exportName(['프로필', profile.name], 'html', 'resume'),
263
310
  mimeType: HTML_MIME,
264
311
  content: toPrintableHtml(title, md.content),
265
312
  };
266
313
  }
267
314
 
315
+ /** Full resume-style profile export → Word (.docx). */
316
+ export async function profileToDocx(
317
+ profile: ProfileRecord,
318
+ experiences: ExperienceRecord[] = [],
319
+ projects: ProjectRecord[] = [],
320
+ skills: SkillRecord[] = [],
321
+ ): Promise<ExportResult> {
322
+ const md = profileToMarkdown(profile, experiences, projects, skills);
323
+ const title = profile.name?.trim() || '이력서';
324
+ const filename = exportName(['프로필', profile.name], 'docx', 'resume');
325
+ const bytes = await markdownToDocx(title, md.content);
326
+ return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
327
+ }
328
+
329
+ /**
330
+ * Full profile → JSON Resume (jsonresume.org, schema v1.0.0). Makes CareerMate
331
+ * data portable: the open standard is consumed by 100+ themes (HTML/PDF render)
332
+ * and other AI/ATS tools. Dates pass through as stored (YYYY-MM[-DD]); only
333
+ * populated fields are emitted to keep the document clean.
334
+ */
335
+ export function profileToJsonResume(
336
+ profile: ProfileRecord,
337
+ experiences: ExperienceRecord[] = [],
338
+ projects: ProjectRecord[] = [],
339
+ skills: SkillRecord[] = [],
340
+ ): ExportResult {
341
+ const clean = <T extends Record<string, unknown>>(o: T): T =>
342
+ Object.fromEntries(
343
+ Object.entries(o).filter(([, v]) => v != null && !(Array.isArray(v) && v.length === 0) && v !== ''),
344
+ ) as T;
345
+
346
+ const basics = clean({
347
+ name: profile.name ?? '',
348
+ label: profile.headline ?? '',
349
+ email: profile.email ?? '',
350
+ phone: profile.phone ?? '',
351
+ summary: profile.summary ?? '',
352
+ location: profile.location ? { address: profile.location } : undefined,
353
+ profiles: (profile.links ?? [])
354
+ .filter((l) => l.url)
355
+ .map((l) => clean({ network: l.label ?? '', url: l.url })),
356
+ });
357
+
358
+ const work = experiences.map((e) =>
359
+ clean({
360
+ name: e.company ?? '',
361
+ position: e.role ?? '',
362
+ startDate: e.start_date ?? '',
363
+ endDate: e.is_current ? '' : (e.end_date ?? ''),
364
+ summary: e.description ?? '',
365
+ highlights: e.achievements ?? [],
366
+ }),
367
+ );
368
+
369
+ const projectsOut = projects.map((p) =>
370
+ clean({
371
+ name: p.name ?? '',
372
+ description: p.description ?? '',
373
+ highlights: p.highlights ?? [],
374
+ keywords: p.tech ?? [],
375
+ url: p.url ?? '',
376
+ startDate: p.start_date ?? '',
377
+ endDate: p.end_date ?? '',
378
+ roles: p.role ? [p.role] : [],
379
+ }),
380
+ );
381
+
382
+ const skillsOut = skills.map((s) => clean({ name: s.name, level: s.level ?? '' }));
383
+
384
+ const resume = {
385
+ $schema: 'https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json',
386
+ basics,
387
+ work,
388
+ projects: projectsOut,
389
+ skills: skillsOut,
390
+ meta: { canonical: 'https://jsonresume.org', version: 'v1.0.0', generatedBy: 'CareerMate' },
391
+ };
392
+
393
+ return {
394
+ filename: exportName(['프로필', profile.name], 'json', 'resume'),
395
+ mimeType: 'application/json',
396
+ content: JSON.stringify(resume, null, 2),
397
+ };
398
+ }
399
+
268
400
  /* ------------------------------------------------------------ interview prep */
269
401
 
270
402
  /** Interview prep record → Markdown study sheet. */
@@ -343,7 +475,7 @@ export function interviewPrepToMarkdown(
343
475
  ]);
344
476
 
345
477
  return {
346
- filename: `${slugify(titleBase, 'interview-prep')}.md`,
478
+ filename: exportName([titleBase], 'md', 'interview_prep'),
347
479
  mimeType: MD_MIME,
348
480
  content,
349
481
  };
@@ -357,7 +489,7 @@ export function interviewPrepToHtml(
357
489
  const md = interviewPrepToMarkdown(prep, job);
358
490
  const title = job ? `${job.company} ${job.position} 면접 준비` : '면접 준비';
359
491
  return {
360
- filename: `${slugify(title, 'interview-prep')}.html`,
492
+ filename: exportName([title], 'html', 'interview_prep'),
361
493
  mimeType: HTML_MIME,
362
494
  content: toPrintableHtml(title, md.content),
363
495
  };
@@ -373,3 +505,4 @@ export {
373
505
  markdownToHtml,
374
506
  slugify,
375
507
  } from './markdown.ts';
508
+ export { DOCX_MIME } from './docx.ts';
@@ -11,19 +11,24 @@ import { escapeHtml } from './html.ts';
11
11
  /* ----------------------------------------------------------- slug / filenames */
12
12
 
13
13
  /**
14
- * Make a filesystem-safe, ASCII-leaning slug from arbitrary (often Korean) text.
15
- * Non-ASCII runs collapse to `-`; the result is lowercased and trimmed of
16
- * leading/trailing separators. Falls back to `document` when nothing survives.
14
+ * Make a filesystem-safe slug from arbitrary text, PRESERVING the original
15
+ * script (Hangul, CJK, Latin, digits) so a Korean title like "백엔드 자기소개서"
16
+ * yields "백엔드_자기소개서" instead of a generic fallback. Spaces and any
17
+ * characters that aren't Unicode letters/numbers (punctuation, path separators)
18
+ * collapse to `_`; the result is trimmed and capped to a sane filename length.
19
+ * Falls back to `fallback` when nothing survives (e.g. emoji-only titles); pass
20
+ * `''` to opt out of the fallback when composing a multi-part name.
21
+ * Unicode filenames are safe on modern OSes, and the download header
22
+ * (`http.ts` `sendDownload`) already allows Hangul and encodes UTF-8.
17
23
  */
18
24
  export function slugify(input: string | null | undefined, fallback = 'document'): string {
19
- const raw = (input ?? '').normalize('NFKD');
20
- // Keep ASCII alphanumerics; everything else becomes a separator.
21
- const slug = raw
22
- .replace(/[^\x00-\x7F]+/g, '-') // drop non-ASCII (e.g. Hangul) -> separator
23
- .replace(/[^a-zA-Z0-9]+/g, '-') // remaining punctuation/space -> separator
24
- .replace(/-+/g, '-')
25
- .replace(/^-+|-+$/g, '')
26
- .toLowerCase();
25
+ const slug = (input ?? '')
26
+ .normalize('NFC')
27
+ .replace(/[^\p{L}\p{N}]+/gu, '_') // non letter/number (space, punctuation, /\:*?…) -> separator
28
+ .replace(/_+/g, '_')
29
+ .replace(/^_+|_+$/g, '')
30
+ .slice(0, 80)
31
+ .replace(/_+$/g, ''); // re-trim a separator left dangling by the length cap
27
32
  return slug || fallback;
28
33
  }
29
34