@sellable/mcp 0.1.269 → 0.1.271

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.
@@ -1,13 +1,34 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { pathToFileURL } from "node:url";
4
6
  const CONTENT_ROOT_ENV = "SELLABLE_CONTENT_DIR";
5
7
  const DEFAULT_PREVIEW_CHARS = 220;
8
+ const LINKEDIN_PREVIEW_CONTRACT_VERSION = "linkedin-preview-rendering/v2";
9
+ const LINKEDIN_PREVIEW_FONT_SIZE_PX = 14;
10
+ const LINKEDIN_PREVIEW_LINE_HEIGHT_PX = 21;
11
+ const LINKEDIN_DEFAULT_CLAMP_LINES = 3;
12
+ const LINKEDIN_MOBILE_CHAR_GUARDRAIL = 140;
13
+ const LINKEDIN_DESKTOP_CHAR_GUARDRAIL = 210;
14
+ const LINKEDIN_PREVIEW_SURFACES = {
15
+ mobile: {
16
+ textWidthPx: 308,
17
+ viewportWidthPx: 390,
18
+ viewportHeightPx: 760,
19
+ },
20
+ desktop: {
21
+ textWidthPx: 582,
22
+ viewportWidthPx: 744,
23
+ viewportHeightPx: 760,
24
+ },
25
+ };
6
26
  const RELATIVE_DIRS = {
7
27
  ideas: "linkedin/ideas",
8
28
  hookResearch: "linkedin/research/hooks",
9
29
  drafts: "linkedin/drafts",
10
30
  published: "linkedin/published",
31
+ previews: "linkedin/previews",
11
32
  commentLibrary: "linkedin/comments/library",
12
33
  };
13
34
  export const contentPostToolDefinitions = [
@@ -104,6 +125,92 @@ export const contentPostToolDefinitions = [
104
125
  additionalProperties: false,
105
126
  },
106
127
  },
128
+ {
129
+ name: "calculate_linkedin_hook_preview",
130
+ description: "Functionally calculate the LinkedIn pre-see-more hook for mobile and desktop. Returns rendered visible lines and rule diagnostics without writing files.",
131
+ inputSchema: {
132
+ type: "object",
133
+ properties: {
134
+ postText: {
135
+ type: "string",
136
+ description: "Full post text or hook block to calculate. Provide either postText or draftId.",
137
+ },
138
+ draftId: {
139
+ type: "string",
140
+ description: "Optional saved draft ID. When postText is omitted, the tool calculates this draft's Draft Body.",
141
+ },
142
+ sourceLabel: { type: "string" },
143
+ measurementMode: {
144
+ type: "string",
145
+ enum: ["browser", "estimated"],
146
+ description: "Defaults to browser. Browser mode uses local Chrome layout measurement when available; estimated mode uses the deterministic width model.",
147
+ },
148
+ requireBrowserMeasurement: {
149
+ type: "boolean",
150
+ description: "Defaults to false. When true, fail if local Chrome cannot measure rendered lines.",
151
+ },
152
+ clampLines: {
153
+ type: "number",
154
+ description: "Visible rendered lines before see-more for both surfaces. Defaults to 3.",
155
+ },
156
+ mobileClampLines: {
157
+ type: "number",
158
+ description: "Optional mobile-specific visible rendered line count. Defaults to clampLines.",
159
+ },
160
+ desktopClampLines: {
161
+ type: "number",
162
+ description: "Optional desktop-specific visible rendered line count. Defaults to clampLines.",
163
+ },
164
+ mobileTextWidthPx: {
165
+ type: "number",
166
+ description: "Optional mobile text width override. Defaults to 308px.",
167
+ },
168
+ desktopTextWidthPx: {
169
+ type: "number",
170
+ description: "Optional desktop text width override. Defaults to 582px.",
171
+ },
172
+ },
173
+ required: [],
174
+ additionalProperties: false,
175
+ },
176
+ },
177
+ {
178
+ name: "render_linkedin_post_preview",
179
+ description: "Render any LinkedIn post text through the Sellable LinkedIn preview CSS contract for mobile and desktop. Writes HTML artifacts and, when local Chrome is available, browser screenshots under ~/.sellable/content/linkedin/previews.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ postText: {
184
+ type: "string",
185
+ description: "Full post text to render. Provide either postText or draftId.",
186
+ },
187
+ draftId: {
188
+ type: "string",
189
+ description: "Optional saved draft ID. When postText is omitted, the tool renders this draft's Draft Body.",
190
+ },
191
+ renderId: {
192
+ type: "string",
193
+ description: "Optional stable render ID. Must be a safe filename without slashes.",
194
+ },
195
+ sourceLabel: { type: "string" },
196
+ createdAt: { type: "string" },
197
+ renderScreenshots: {
198
+ type: "boolean",
199
+ description: "Defaults to true. When true, tries to use local Chrome headless to write PNG screenshots.",
200
+ },
201
+ requireScreenshots: {
202
+ type: "boolean",
203
+ description: "Defaults to false. When true, fail if local Chrome cannot produce PNG screenshots.",
204
+ },
205
+ reviewClampLines: {
206
+ type: "number",
207
+ description: "Visible review lines before see-more. Defaults to 3.",
208
+ },
209
+ },
210
+ required: [],
211
+ additionalProperties: false,
212
+ },
213
+ },
107
214
  {
108
215
  name: "update_post_draft",
109
216
  description: "Update an existing LinkedIn post draft in place while preserving omitted fields. Useful for adding iteration receipts, status changes, or final copy edits without rewriting the whole artifact.",
@@ -389,6 +496,147 @@ export function savePostDraftTool(input) {
389
496
  preview: sanitizedPreview(input.body),
390
497
  };
391
498
  }
499
+ export function calculateLinkedInHookPreviewTool(input) {
500
+ const { postText, draftId } = resolveLinkedInPreviewPostText(input);
501
+ const defaultClampLines = boundedWholeNumber(input.clampLines, LINKEDIN_DEFAULT_CLAMP_LINES, 1, 8);
502
+ const measurementMode = input.measurementMode ?? "browser";
503
+ const mobile = calculateLinkedInHookPreviewSurface({
504
+ label: "mobile",
505
+ text: postText,
506
+ textWidthPx: boundedWholeNumber(input.mobileTextWidthPx, LINKEDIN_PREVIEW_SURFACES.mobile.textWidthPx, 160, 900),
507
+ clampLines: boundedWholeNumber(input.mobileClampLines, defaultClampLines, 1, 8),
508
+ measurementMode,
509
+ requireBrowserMeasurement: input.requireBrowserMeasurement,
510
+ });
511
+ const desktop = calculateLinkedInHookPreviewSurface({
512
+ label: "desktop",
513
+ text: postText,
514
+ textWidthPx: boundedWholeNumber(input.desktopTextWidthPx, LINKEDIN_PREVIEW_SURFACES.desktop.textWidthPx, 260, 1200),
515
+ clampLines: boundedWholeNumber(input.desktopClampLines, defaultClampLines, 1, 8),
516
+ measurementMode,
517
+ requireBrowserMeasurement: input.requireBrowserMeasurement,
518
+ });
519
+ return {
520
+ basis: "linkedin_rendering_rule_function",
521
+ ruleConfidence: "calibrated_from_observed_linkedin_feed_screenshots_and_public_line_clamp_behavior",
522
+ cssContractVersion: LINKEDIN_PREVIEW_CONTRACT_VERSION,
523
+ sourceLabel: input.sourceLabel || draftId,
524
+ draftId,
525
+ ruleSummary: "The hook is the text visible before see more. The review gate is rendered line count, not a fixed character count. Blank lines consume visible lines.",
526
+ rules: {
527
+ visibleUnit: "rendered_visual_line",
528
+ defaultClampLines: LINKEDIN_DEFAULT_CLAMP_LINES,
529
+ fontSizePx: LINKEDIN_PREVIEW_FONT_SIZE_PX,
530
+ lineHeightPx: LINKEDIN_PREVIEW_LINE_HEIGHT_PX,
531
+ whiteSpace: "pre-wrap",
532
+ overflowWrap: "break-word",
533
+ desktopCharGuardrail: LINKEDIN_DESKTOP_CHAR_GUARDRAIL,
534
+ mobileCharGuardrail: LINKEDIN_MOBILE_CHAR_GUARDRAIL,
535
+ characterCountsAreGuardrailsOnly: true,
536
+ notOfficialLinkedInApi: true,
537
+ strongestEvidence: "authenticated LinkedIn feed screenshot on the target surface",
538
+ },
539
+ mobile,
540
+ desktop,
541
+ diagnostics: previewDiagnostics(postText),
542
+ };
543
+ }
544
+ export function renderLinkedInPostPreviewTool(input) {
545
+ const now = normalizeDate(input.createdAt);
546
+ const { postText, draftId } = resolveLinkedInPreviewPostText(input);
547
+ const reviewClampLines = typeof input.reviewClampLines === "number" &&
548
+ Number.isFinite(input.reviewClampLines)
549
+ ? Math.max(1, Math.min(Math.floor(input.reviewClampLines), 8))
550
+ : 3;
551
+ const id = input.renderId ??
552
+ `preview_${dateStamp(now)}_${slugify(input.sourceLabel || draftId || postText)}`;
553
+ const safeId = normalizeArtifactId(id, "renderId");
554
+ const baseDir = `${RELATIVE_DIRS.previews}/${safeId}`;
555
+ const title = input.sourceLabel || draftId || safeId;
556
+ const renderScreenshots = input.renderScreenshots !== false;
557
+ const mobile = buildPreviewSurface({
558
+ label: "mobile",
559
+ text: postText,
560
+ baseDir,
561
+ textWidthPx: LINKEDIN_PREVIEW_SURFACES.mobile.textWidthPx,
562
+ viewportWidthPx: LINKEDIN_PREVIEW_SURFACES.mobile.viewportWidthPx,
563
+ viewportHeightPx: LINKEDIN_PREVIEW_SURFACES.mobile.viewportHeightPx,
564
+ reviewClampLines,
565
+ });
566
+ const desktop = buildPreviewSurface({
567
+ label: "desktop",
568
+ text: postText,
569
+ baseDir,
570
+ textWidthPx: LINKEDIN_PREVIEW_SURFACES.desktop.textWidthPx,
571
+ viewportWidthPx: LINKEDIN_PREVIEW_SURFACES.desktop.viewportWidthPx,
572
+ viewportHeightPx: LINKEDIN_PREVIEW_SURFACES.desktop.viewportHeightPx,
573
+ reviewClampLines,
574
+ });
575
+ const combinedHtmlRelativePath = `${baseDir}/preview.html`;
576
+ const combinedHtml = buildCombinedPreviewHtml({
577
+ title,
578
+ postText,
579
+ mobile,
580
+ desktop,
581
+ });
582
+ writeContentFile(combinedHtmlRelativePath, combinedHtml);
583
+ const screenshotResult = renderScreenshots
584
+ ? renderPreviewScreenshots([mobile, desktop], input.requireScreenshots)
585
+ : { status: "skipped", reason: "renderScreenshots=false" };
586
+ const metadata = {
587
+ id: safeId,
588
+ type: "linkedin_preview",
589
+ status: screenshotResult.status === "rendered" ? "rendered" : "html_rendered",
590
+ title,
591
+ draftId,
592
+ createdAt: now,
593
+ updatedAt: now,
594
+ };
595
+ const renderedPreview = {
596
+ basis: screenshotResult.status === "rendered"
597
+ ? "local_chrome_headless_screenshot"
598
+ : "linkedin_css_contract_html",
599
+ cssContractVersion: LINKEDIN_PREVIEW_CONTRACT_VERSION,
600
+ generatedAt: now,
601
+ sourceLabel: title,
602
+ mobile: surfaceRecord(mobile),
603
+ desktop: surfaceRecord(desktop),
604
+ diagnostics: previewDiagnostics(postText),
605
+ screenshotStatus: screenshotResult,
606
+ };
607
+ const recordRelativePath = `${baseDir}/render.md`;
608
+ const metadataJsonRelativePath = `${baseDir}/metadata.json`;
609
+ writeContentFile(metadataJsonRelativePath, `${JSON.stringify(renderedPreview, null, 2)}\n`);
610
+ writeArtifact(recordRelativePath, buildMarkdown(metadata, [
611
+ ["Rendered Preview Record", jsonBlock(renderedPreview)],
612
+ [
613
+ "Artifacts",
614
+ [
615
+ `- combinedHtml: ${combinedHtmlRelativePath}`,
616
+ `- mobileHtml: ${mobile.htmlPath}`,
617
+ mobile.screenshotPath
618
+ ? `- mobileScreenshot: ${mobile.screenshotPath}`
619
+ : "- mobileScreenshot: none",
620
+ `- desktopHtml: ${desktop.htmlPath}`,
621
+ desktop.screenshotPath
622
+ ? `- desktopScreenshot: ${desktop.screenshotPath}`
623
+ : "- desktopScreenshot: none",
624
+ ].join("\n"),
625
+ ],
626
+ ]));
627
+ return {
628
+ id: safeId,
629
+ path: recordRelativePath,
630
+ artifactDir: baseDir,
631
+ absoluteArtifactDir: safePath(resolveContentRoot(), baseDir),
632
+ combinedHtmlPath: combinedHtmlRelativePath,
633
+ absoluteCombinedHtmlPath: safePath(resolveContentRoot(), combinedHtmlRelativePath),
634
+ mobile: surfaceReturnRecord(mobile),
635
+ desktop: surfaceReturnRecord(desktop),
636
+ screenshotStatus: screenshotResult,
637
+ renderedPreview,
638
+ };
639
+ }
392
640
  export function updatePostDraftTool(input) {
393
641
  const safeDraftId = normalizeArtifactId(input.draftId, "draftId");
394
642
  const relativePath = `${RELATIVE_DIRS.drafts}/${safeDraftId}.md`;
@@ -617,6 +865,555 @@ export function listPublishedPostsTool(input) {
617
865
  }
618
866
  return summarizeArtifacts(artifacts, input?.limit);
619
867
  }
868
+ function resolveLinkedInPreviewPostText(input) {
869
+ const draftId = input.draftId
870
+ ? normalizeArtifactId(input.draftId, "draftId")
871
+ : undefined;
872
+ const draftBody = draftId
873
+ ? extractMarkdownSection(getArtifact(RELATIVE_DIRS.drafts, draftId, "draftId").markdown, "Draft Body")
874
+ : undefined;
875
+ const postText = input.postText ?? draftBody;
876
+ requireString(postText, "postText");
877
+ return { postText, draftId };
878
+ }
879
+ function boundedWholeNumber(value, fallback, min, max) {
880
+ if (typeof value !== "number" || !Number.isFinite(value))
881
+ return fallback;
882
+ return Math.max(min, Math.min(Math.floor(value), max));
883
+ }
884
+ function calculateLinkedInHookPreviewSurface(input) {
885
+ const measurement = measureLinkedInRenderedLines({
886
+ text: input.text,
887
+ textWidthPx: input.textWidthPx,
888
+ measurementMode: input.measurementMode,
889
+ requireBrowserMeasurement: input.requireBrowserMeasurement,
890
+ });
891
+ const visibleLines = measurement.lines.slice(0, input.clampLines);
892
+ const hiddenLines = measurement.lines.slice(input.clampLines);
893
+ const visibleTextBlock = visibleLines.join("\n");
894
+ const charGuardrail = input.label === "mobile"
895
+ ? LINKEDIN_MOBILE_CHAR_GUARDRAIL
896
+ : LINKEDIN_DESKTOP_CHAR_GUARDRAIL;
897
+ return {
898
+ label: input.label,
899
+ textWidthPx: input.textWidthPx,
900
+ fontSizePx: LINKEDIN_PREVIEW_FONT_SIZE_PX,
901
+ lineHeightPx: LINKEDIN_PREVIEW_LINE_HEIGHT_PX,
902
+ clampLines: input.clampLines,
903
+ charGuardrail,
904
+ renderedLines: measurement.lines,
905
+ visibleLines,
906
+ visibleTextBlock,
907
+ visibleTextBlockCharCount: visibleTextBlock.length,
908
+ withinCharGuardrail: visibleTextBlock.length <= charGuardrail,
909
+ lineCountBeforeClamp: measurement.lines.length,
910
+ blankLinesBeforeClamp: visibleLines.filter((line) => line.trim().length === 0).length,
911
+ truncated: hiddenLines.length > 0,
912
+ seeMoreWouldShow: hiddenLines.length > 0,
913
+ hiddenTextStartsWith: hiddenLines.find((line) => line.trim().length > 0),
914
+ measurementBasis: measurement.basis,
915
+ measurementWarning: measurement.warning,
916
+ chromePath: measurement.chromePath,
917
+ };
918
+ }
919
+ function measureLinkedInRenderedLines(input) {
920
+ if (input.measurementMode === "browser") {
921
+ const browserMeasurement = measureLinkedInRenderedLinesWithChrome(input.text, input.textWidthPx);
922
+ if (browserMeasurement)
923
+ return browserMeasurement;
924
+ if (input.requireBrowserMeasurement) {
925
+ throw new Error("Chrome executable not found or browser line measurement failed. Set CHROME_PATH or use measurementMode=estimated.");
926
+ }
927
+ }
928
+ return {
929
+ lines: estimateRenderedLines(input.text, input.textWidthPx),
930
+ basis: "estimated_width_model",
931
+ warning: input.measurementMode === "browser"
932
+ ? "local Chrome measurement unavailable; used deterministic width estimate"
933
+ : undefined,
934
+ };
935
+ }
936
+ function measureLinkedInRenderedLinesWithChrome(text, textWidthPx) {
937
+ const chromePath = findChromeExecutable();
938
+ if (!chromePath)
939
+ return null;
940
+ const htmlPath = path.join(os.tmpdir(), `sellable-linkedin-hook-preview-${process.pid}-${Date.now()}-${Math.random()
941
+ .toString(36)
942
+ .slice(2)}.html`);
943
+ try {
944
+ fs.writeFileSync(htmlPath, buildLineMeasurementHtml(text, textWidthPx), "utf8");
945
+ const dom = execFileSync(chromePath, [
946
+ "--headless=new",
947
+ "--disable-gpu",
948
+ "--no-sandbox",
949
+ "--disable-dev-shm-usage",
950
+ "--hide-scrollbars",
951
+ "--dump-dom",
952
+ pathToFileURL(htmlPath).href,
953
+ ], { encoding: "utf8", timeout: 20000, stdio: ["ignore", "pipe", "ignore"] });
954
+ const encoded = dom.match(/data-linkedin-lines="([^"]+)"/)?.[1];
955
+ if (!encoded)
956
+ return null;
957
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
958
+ const parsed = JSON.parse(decoded);
959
+ if (!Array.isArray(parsed) ||
960
+ parsed.some((line) => typeof line !== "string")) {
961
+ return null;
962
+ }
963
+ return {
964
+ lines: parsed,
965
+ basis: "local_chrome_browser_layout",
966
+ chromePath,
967
+ };
968
+ }
969
+ catch {
970
+ return null;
971
+ }
972
+ finally {
973
+ fs.rmSync(htmlPath, { force: true });
974
+ }
975
+ }
976
+ function buildLineMeasurementHtml(text, textWidthPx) {
977
+ return [
978
+ "<!doctype html>",
979
+ '<html lang="en">',
980
+ "<head>",
981
+ '<meta charset="utf-8">',
982
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
983
+ "</head>",
984
+ "<body>",
985
+ "<script>",
986
+ `const text = ${jsonForInlineScript(text)};`,
987
+ `const textWidthPx = ${textWidthPx};`,
988
+ `const fontSizePx = ${LINKEDIN_PREVIEW_FONT_SIZE_PX};`,
989
+ `const lineHeightPx = ${LINKEDIN_PREVIEW_LINE_HEIGHT_PX};`,
990
+ `
991
+ const div = document.createElement("div");
992
+ div.style.cssText = [
993
+ "position:absolute",
994
+ "left:0",
995
+ "top:0",
996
+ "width:" + textWidthPx + "px",
997
+ "font-family:-apple-system,system-ui,'Segoe UI',Roboto,'Helvetica Neue','Fira Sans',Ubuntu,'Oxygen Sans',Cantarell,'Droid Sans','Lucida Grande',Helvetica,Arial,sans-serif",
998
+ "font-size:" + fontSizePx + "px",
999
+ "line-height:" + lineHeightPx + "px",
1000
+ "font-weight:400",
1001
+ "letter-spacing:normal",
1002
+ "white-space:pre-wrap",
1003
+ "overflow-wrap:break-word",
1004
+ "color:#000"
1005
+ ].join(";");
1006
+ Array.from(text).forEach((char, index) => {
1007
+ if (char === "\\n") {
1008
+ div.appendChild(document.createTextNode("\\n"));
1009
+ return;
1010
+ }
1011
+ const span = document.createElement("span");
1012
+ span.dataset.index = String(index);
1013
+ span.textContent = char;
1014
+ div.appendChild(span);
1015
+ });
1016
+ document.body.appendChild(div);
1017
+
1018
+ const lines = [];
1019
+ let currentText = null;
1020
+ let currentTop = null;
1021
+ let previousTop = null;
1022
+ function flushLine() {
1023
+ if (currentText !== null) {
1024
+ lines.push(currentText.trimEnd());
1025
+ currentText = null;
1026
+ currentTop = null;
1027
+ }
1028
+ }
1029
+
1030
+ for (const span of div.querySelectorAll("span")) {
1031
+ const rect = span.getClientRects()[0];
1032
+ if (!rect) continue;
1033
+ const top = Math.round(rect.top * 10) / 10;
1034
+ if (currentTop === null || Math.abs(top - currentTop) > 2) {
1035
+ flushLine();
1036
+ if (previousTop !== null) {
1037
+ const blankCount = Math.max(0, Math.round((top - previousTop) / lineHeightPx) - 1);
1038
+ for (let index = 0; index < blankCount; index += 1) lines.push("");
1039
+ }
1040
+ currentText = "";
1041
+ currentTop = top;
1042
+ previousTop = top;
1043
+ }
1044
+ currentText += span.textContent;
1045
+ }
1046
+ flushLine();
1047
+ document.documentElement.setAttribute(
1048
+ "data-linkedin-lines",
1049
+ btoa(unescape(encodeURIComponent(JSON.stringify(lines))))
1050
+ );
1051
+ `,
1052
+ "</script>",
1053
+ "</body>",
1054
+ "</html>",
1055
+ "",
1056
+ ].join("\n");
1057
+ }
1058
+ function jsonForInlineScript(value) {
1059
+ return JSON.stringify(value).replace(/</g, "\\u003c");
1060
+ }
1061
+ function buildPreviewSurface(input) {
1062
+ const estimatedLines = estimateRenderedLines(input.text, input.textWidthPx);
1063
+ const htmlPath = `${input.baseDir}/${input.label}.html`;
1064
+ const surface = {
1065
+ label: input.label,
1066
+ textWidthPx: input.textWidthPx,
1067
+ viewportWidthPx: input.viewportWidthPx,
1068
+ viewportHeightPx: input.viewportHeightPx,
1069
+ fontSizePx: LINKEDIN_PREVIEW_FONT_SIZE_PX,
1070
+ lineHeightPx: LINKEDIN_PREVIEW_LINE_HEIGHT_PX,
1071
+ reviewClampLines: input.reviewClampLines,
1072
+ estimatedLines,
1073
+ estimatedVisibleTextBlock: estimatedLines
1074
+ .slice(0, input.reviewClampLines)
1075
+ .join("\n"),
1076
+ estimatedLineCountBeforeClamp: estimatedLines.length,
1077
+ estimatedBlankLinesBeforeClamp: estimatedLines
1078
+ .slice(0, input.reviewClampLines)
1079
+ .filter((line) => line.trim().length === 0).length,
1080
+ htmlPath,
1081
+ absoluteHtmlPath: safePath(resolveContentRoot(), htmlPath),
1082
+ };
1083
+ writeContentFile(htmlPath, buildSinglePreviewHtml({
1084
+ title: `${input.label} LinkedIn preview`,
1085
+ postText: input.text,
1086
+ surface,
1087
+ }));
1088
+ return surface;
1089
+ }
1090
+ function buildSinglePreviewHtml(input) {
1091
+ return htmlDocument({
1092
+ title: input.title,
1093
+ bodyClass: input.surface.label,
1094
+ body: linkedInCardHtml(input.postText, input.surface),
1095
+ });
1096
+ }
1097
+ function buildCombinedPreviewHtml(input) {
1098
+ return htmlDocument({
1099
+ title: `LinkedIn preview: ${input.title}`,
1100
+ bodyClass: "combined",
1101
+ body: [
1102
+ `<main class="combined-grid">`,
1103
+ `<section><h1>Mobile preview</h1>${linkedInCardHtml(input.postText, input.mobile)}</section>`,
1104
+ `<section><h1>Desktop preview</h1>${linkedInCardHtml(input.postText, input.desktop)}</section>`,
1105
+ `</main>`,
1106
+ ].join("\n"),
1107
+ });
1108
+ }
1109
+ function htmlDocument(input) {
1110
+ return [
1111
+ "<!doctype html>",
1112
+ '<html lang="en">',
1113
+ "<head>",
1114
+ '<meta charset="utf-8">',
1115
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
1116
+ `<title>${escapeHtml(input.title)}</title>`,
1117
+ "<style>",
1118
+ linkedinPreviewCss(),
1119
+ "</style>",
1120
+ "</head>",
1121
+ `<body class="${escapeHtml(input.bodyClass)}">`,
1122
+ input.body,
1123
+ "</body>",
1124
+ "</html>",
1125
+ "",
1126
+ ].join("\n");
1127
+ }
1128
+ function linkedInCardHtml(postText, surface) {
1129
+ const escapedText = escapeHtml(postText);
1130
+ const cardWidth = surface.textWidthPx + 32;
1131
+ return [
1132
+ `<article class="linkedin-card" data-surface="${surface.label}" style="width:${cardWidth}px">`,
1133
+ '<header class="post-header" aria-hidden="true">',
1134
+ '<div class="avatar"></div>',
1135
+ '<div class="byline"><div class="name">Preview Author</div><div class="meta">LinkedIn preview contract</div></div>',
1136
+ "</header>",
1137
+ `<div class="post-text" style="width:${surface.textWidthPx}px;-webkit-line-clamp:${surface.reviewClampLines}">${escapedText}</div>`,
1138
+ surface.estimatedLineCountBeforeClamp > surface.reviewClampLines
1139
+ ? '<div class="see-more">...see more</div>'
1140
+ : "",
1141
+ '<div class="post-actions" aria-hidden="true"><span>Like</span><span>Comment</span><span>Repost</span></div>',
1142
+ "</article>",
1143
+ ].join("\n");
1144
+ }
1145
+ function linkedinPreviewCss() {
1146
+ return `
1147
+ :root {
1148
+ color-scheme: light;
1149
+ --linkedin-text: rgba(0, 0, 0, 0.9);
1150
+ --linkedin-muted: rgba(0, 0, 0, 0.6);
1151
+ --linkedin-border: #e0dfdc;
1152
+ --linkedin-bg: #f3f2ef;
1153
+ }
1154
+ * { box-sizing: border-box; }
1155
+ body {
1156
+ margin: 0;
1157
+ min-height: 100vh;
1158
+ background: var(--linkedin-bg);
1159
+ font-family: -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue",
1160
+ "Fira Sans", Ubuntu, "Oxygen Sans", Cantarell, "Droid Sans",
1161
+ "Lucida Grande", Helvetica, Arial, sans-serif;
1162
+ }
1163
+ body.mobile, body.desktop {
1164
+ display: flex;
1165
+ align-items: flex-start;
1166
+ justify-content: center;
1167
+ padding: 20px;
1168
+ }
1169
+ .combined-grid {
1170
+ display: grid;
1171
+ grid-template-columns: max-content max-content;
1172
+ gap: 28px;
1173
+ align-items: start;
1174
+ padding: 20px;
1175
+ }
1176
+ h1 {
1177
+ margin: 0 0 10px;
1178
+ font-size: 14px;
1179
+ line-height: 20px;
1180
+ font-weight: 600;
1181
+ color: var(--linkedin-muted);
1182
+ }
1183
+ .linkedin-card {
1184
+ background: #fff;
1185
+ border: 1px solid var(--linkedin-border);
1186
+ border-radius: 8px;
1187
+ padding: 12px 16px 10px;
1188
+ color: var(--linkedin-text);
1189
+ }
1190
+ .post-header {
1191
+ display: flex;
1192
+ align-items: center;
1193
+ gap: 8px;
1194
+ margin-bottom: 8px;
1195
+ }
1196
+ .avatar {
1197
+ width: 40px;
1198
+ height: 40px;
1199
+ border-radius: 50%;
1200
+ background: linear-gradient(135deg, #d7dce0, #eef0f2);
1201
+ }
1202
+ .byline { min-width: 0; }
1203
+ .name {
1204
+ height: 16px;
1205
+ font-size: 14px;
1206
+ line-height: 16px;
1207
+ font-weight: 600;
1208
+ }
1209
+ .meta {
1210
+ font-size: 12px;
1211
+ line-height: 14px;
1212
+ color: var(--linkedin-muted);
1213
+ }
1214
+ .post-text {
1215
+ font-size: 14px;
1216
+ line-height: 21px;
1217
+ font-weight: 400;
1218
+ letter-spacing: normal;
1219
+ white-space: pre-wrap;
1220
+ overflow-wrap: break-word;
1221
+ color: var(--linkedin-text);
1222
+ overflow: hidden;
1223
+ display: -webkit-box;
1224
+ -webkit-box-orient: vertical;
1225
+ }
1226
+ .see-more {
1227
+ margin-top: 2px;
1228
+ font-size: 14px;
1229
+ line-height: 21px;
1230
+ color: var(--linkedin-muted);
1231
+ }
1232
+ .post-actions {
1233
+ display: flex;
1234
+ gap: 18px;
1235
+ border-top: 1px solid var(--linkedin-border);
1236
+ margin-top: 10px;
1237
+ padding-top: 8px;
1238
+ color: var(--linkedin-muted);
1239
+ font-size: 13px;
1240
+ line-height: 18px;
1241
+ }
1242
+ `;
1243
+ }
1244
+ function renderPreviewScreenshots(surfaces, requireScreenshots) {
1245
+ const chromePath = findChromeExecutable();
1246
+ if (!chromePath) {
1247
+ if (requireScreenshots) {
1248
+ throw new Error("Chrome executable not found. Set CHROME_PATH or install Google Chrome to render LinkedIn preview screenshots.");
1249
+ }
1250
+ return { status: "skipped", reason: "chrome_not_found" };
1251
+ }
1252
+ try {
1253
+ for (const surface of surfaces) {
1254
+ const screenshotPath = surface.htmlPath.replace(/\.html$/, ".png");
1255
+ const absoluteScreenshotPath = safePath(resolveContentRoot(), screenshotPath);
1256
+ execFileSync(chromePath, [
1257
+ "--headless=new",
1258
+ "--disable-gpu",
1259
+ "--no-sandbox",
1260
+ "--disable-dev-shm-usage",
1261
+ "--hide-scrollbars",
1262
+ `--screenshot=${absoluteScreenshotPath}`,
1263
+ `--window-size=${surface.viewportWidthPx},${surface.viewportHeightPx}`,
1264
+ pathToFileURL(surface.absoluteHtmlPath).href,
1265
+ ], { stdio: "ignore", timeout: 20000 });
1266
+ surface.screenshotPath = screenshotPath;
1267
+ surface.absoluteScreenshotPath = absoluteScreenshotPath;
1268
+ }
1269
+ return { status: "rendered", chromePath };
1270
+ }
1271
+ catch (error) {
1272
+ const reason = error instanceof Error ? error.message : String(error);
1273
+ if (requireScreenshots) {
1274
+ throw new Error(`Chrome screenshot render failed: ${reason}`);
1275
+ }
1276
+ return { status: "failed", reason, chromePath };
1277
+ }
1278
+ }
1279
+ function findChromeExecutable() {
1280
+ const candidates = [
1281
+ process.env.CHROME_PATH,
1282
+ process.env.GOOGLE_CHROME_BIN,
1283
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1284
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
1285
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
1286
+ "/usr/bin/google-chrome",
1287
+ "/usr/bin/google-chrome-stable",
1288
+ "/usr/bin/chromium",
1289
+ "/usr/bin/chromium-browser",
1290
+ "/snap/bin/chromium",
1291
+ ].filter(Boolean);
1292
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
1293
+ }
1294
+ function surfaceRecord(surface) {
1295
+ return {
1296
+ textWidthPx: surface.textWidthPx,
1297
+ viewportWidthPx: surface.viewportWidthPx,
1298
+ fontSizePx: surface.fontSizePx,
1299
+ lineHeightPx: surface.lineHeightPx,
1300
+ reviewClampLines: surface.reviewClampLines,
1301
+ estimatedVisibleTextBlock: surface.estimatedVisibleTextBlock,
1302
+ estimatedRenderedLines: surface.estimatedLines.slice(0, surface.reviewClampLines),
1303
+ estimatedLineCountBeforeClamp: surface.estimatedLineCountBeforeClamp,
1304
+ estimatedBlankLinesBeforeClamp: surface.estimatedBlankLinesBeforeClamp,
1305
+ htmlPath: surface.htmlPath,
1306
+ screenshotPath: surface.screenshotPath,
1307
+ };
1308
+ }
1309
+ function surfaceReturnRecord(surface) {
1310
+ return {
1311
+ ...surfaceRecord(surface),
1312
+ absoluteHtmlPath: surface.absoluteHtmlPath,
1313
+ absoluteScreenshotPath: surface.absoluteScreenshotPath,
1314
+ };
1315
+ }
1316
+ function previewDiagnostics(text) {
1317
+ const physicalLines = text.split("\n");
1318
+ const nonblankLines = physicalLines.filter((line) => line.trim().length > 0);
1319
+ return {
1320
+ charCount: text.replace(/\s/g, "").length,
1321
+ charCountIncludingNewlines: text.length,
1322
+ firstLineChars: physicalLines[0]?.length ?? 0,
1323
+ firstTwoPhysicalLinesChars: physicalLines.slice(0, 2).join("\n").length,
1324
+ physicalLineCount: physicalLines.length,
1325
+ contentLineCount: nonblankLines.length,
1326
+ longestNonblankLineChars: nonblankLines.reduce((max, line) => Math.max(max, line.length), 0),
1327
+ blankLineCount: physicalLines.length - nonblankLines.length,
1328
+ };
1329
+ }
1330
+ function estimateRenderedLines(text, textWidthPx) {
1331
+ const physicalLines = text.split("\n");
1332
+ const rendered = [];
1333
+ for (const physicalLine of physicalLines) {
1334
+ if (physicalLine.length === 0) {
1335
+ rendered.push("");
1336
+ continue;
1337
+ }
1338
+ rendered.push(...wrapPhysicalLine(physicalLine, textWidthPx));
1339
+ }
1340
+ return rendered;
1341
+ }
1342
+ function wrapPhysicalLine(line, textWidthPx) {
1343
+ const tokens = line.match(/\S+\s*/g) ?? [line];
1344
+ const wrapped = [];
1345
+ let current = "";
1346
+ for (const token of tokens) {
1347
+ const candidate = `${current}${token}`;
1348
+ if (!current || measureTextPx(candidate) <= textWidthPx) {
1349
+ current = candidate;
1350
+ continue;
1351
+ }
1352
+ wrapped.push(current.trimEnd());
1353
+ if (measureTextPx(token) <= textWidthPx) {
1354
+ current = token;
1355
+ }
1356
+ else {
1357
+ const broken = breakLongToken(token.trimEnd(), textWidthPx);
1358
+ wrapped.push(...broken.slice(0, -1));
1359
+ current = broken.at(-1) ?? "";
1360
+ if (token.endsWith(" "))
1361
+ current += " ";
1362
+ }
1363
+ }
1364
+ if (current.length > 0)
1365
+ wrapped.push(current.trimEnd());
1366
+ return wrapped.length > 0 ? wrapped : [""];
1367
+ }
1368
+ function breakLongToken(token, textWidthPx) {
1369
+ const parts = [];
1370
+ let current = "";
1371
+ for (const char of [...token]) {
1372
+ const candidate = `${current}${char}`;
1373
+ if (!current || measureTextPx(candidate) <= textWidthPx) {
1374
+ current = candidate;
1375
+ }
1376
+ else {
1377
+ parts.push(current);
1378
+ current = char;
1379
+ }
1380
+ }
1381
+ if (current)
1382
+ parts.push(current);
1383
+ return parts;
1384
+ }
1385
+ function measureTextPx(value) {
1386
+ let width = 0;
1387
+ for (const char of [...value]) {
1388
+ width += estimateCharWidthPx(char);
1389
+ }
1390
+ return width;
1391
+ }
1392
+ function estimateCharWidthPx(char) {
1393
+ if (char === " ")
1394
+ return 3.6;
1395
+ if (/[ilI|.,:;!'`]/.test(char))
1396
+ return 3.8;
1397
+ if (/[fjrt()[\]{}]/.test(char))
1398
+ return 5.2;
1399
+ if (/[mwMW@#%&]/.test(char))
1400
+ return 10.2;
1401
+ if (/[A-Z]/.test(char))
1402
+ return 8.2;
1403
+ if (/[0-9]/.test(char))
1404
+ return 7.3;
1405
+ if (/[^\x00-\x7F]/.test(char))
1406
+ return 8.4;
1407
+ return 7.0;
1408
+ }
1409
+ function escapeHtml(value) {
1410
+ return value
1411
+ .replace(/&/g, "&amp;")
1412
+ .replace(/</g, "&lt;")
1413
+ .replace(/>/g, "&gt;")
1414
+ .replace(/"/g, "&quot;")
1415
+ .replace(/'/g, "&#39;");
1416
+ }
620
1417
  function getArtifact(relativeDir, id, label) {
621
1418
  const safeId = normalizeArtifactId(id, label);
622
1419
  const relativePath = `${relativeDir}/${safeId}.md`;
@@ -810,6 +1607,11 @@ function writeArtifact(relativePath, markdown) {
810
1607
  const filePath = safeWritablePath(root, relativePath);
811
1608
  fs.writeFileSync(filePath, markdown);
812
1609
  }
1610
+ function writeContentFile(relativePath, contents) {
1611
+ const root = resolveContentRoot();
1612
+ const filePath = safeWritablePath(root, relativePath);
1613
+ fs.writeFileSync(filePath, contents);
1614
+ }
813
1615
  function safePath(root, relativePath) {
814
1616
  validateRelativePath(relativePath);
815
1617
  const fullPath = path.resolve(root, ...relativePath.split("/"));